First version

This commit is contained in:
2026-08-11 12:38:02 -03:00
parent 42e875d866
commit 89d1a06204
37 changed files with 5660 additions and 4 deletions
+30 -1
View File
@@ -1,3 +1,32 @@
## Version 1.0 ## Version 1.0
Initial release 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.
- **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.
+136
View File
@@ -0,0 +1,136 @@
# CLAUDE.md
Context for working on this repository. Feature-level detail lives in
[`docs/features/`](docs/features/README.md) — read the relevant file there before
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.
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.
## Hard constraints (do not re-derive these)
**Nova extension API**
| Constraint | Consequence |
| --- | --- |
| No webview / HTML UI for extensions | The chat surface is a Markdown tab, not a rendered view |
| Sidebar sections are **TreeView only** | No text fields, forms, or the multi-line box Nova's own Source Control sidebar has |
| No WebSocket in the JS runtime | The [IDE bridge](docs/features/ide-bridge.md) needs a Node sidecar |
| No terminal API | Cannot create a terminal or send it text |
| No diff viewer API | Diffs are rendered text; proposals are editable staged files |
| Cannot close tabs programmatically | Never promise to clean up a tab |
| Can only read its **own** `IssueCollection` | No access to other extensions' diagnostics |
| No multi-line input | Long-form input = open a scratch document |
| Commands may only be placed in `editor`, `extensions`, `command-palette`, `text` | No file-browser context menu |
| Untitled documents cannot be saved without a dialog | Stage real files when a save gesture matters |
The runtime is JavaScriptCore with `fetch`, Streams, `TextEncoder/Decoder`,
`setTimeout`, `nova.crypto`**not Node**. No `require` of npm packages; only
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:
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.
**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
shells out to `claude auth login`. See [authentication](docs/features/authentication.md).
## Layout
```
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
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
```
## Conventions
- 4-space indent, CommonJS, no build step, no dependencies.
- Comments explain *why*, especially where a workaround encodes an API
limitation. Several exist purely to stop a future reader "simplifying" a
constraint back into a bug.
- User-visible strings are sentence case and plain English; no jargon from the
protocol leaks into the UI (`friendlyToolName`, `modeLabel`).
- Config keys are namespaced `claudenova.*`; read them through `util.conf()`,
which prefers the workspace value.
## Testing
**The extension has never been run inside Nova.** Everything below is verified
outside it; a first in-Nova run is still owed. Load it with:
```sh
nova extension validate . # manifest + bundle (makes network calls — needs connectivity)
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:
```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");
```
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`.
> 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.
Cheap checks that need no CLI:
```sh
for f in Scripts/*.js; do node --check "$f"; done
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).
- `nova extension validate` last passed cleanly on the schema; a later run
failed only on network reachability from a sandbox, not on the bundle.
- 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

+3
View File
@@ -0,0 +1,3 @@
{
"template": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 976 B

+3
View File
@@ -0,0 +1,3 @@
{
"template": true
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 B

+3
View File
@@ -0,0 +1,3 @@
{
"template": true
}
+232
View File
@@ -0,0 +1,232 @@
# 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.
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.
## 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)
## 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.
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 |
## 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.
## 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 | |
| Set Model… / Set Permission Mode… | |
**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.
**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.
**Resume Session…** lists this project's recent Claude Code sessions, labelled
with their opening prompt, including ones started in a terminal.
## 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:
- **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
## 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.
## Development
```sh
nova extension validate . # check the bundle
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.
+234
View File
@@ -0,0 +1,234 @@
//
// auth.js — account status and sign-in, delegated to the Claude Code CLI.
//
// The extension never implements the OAuth flow itself: `claude auth login`
// runs Anthropic's own flow and stores the credentials where Claude Code
// expects them. We drive that command, surface the URL, and relay the code.
//
const { log, warn, runCommand, processWriter } = require("./util.js");
const cli = require("./cli.js");
const URL_PATTERN = new RegExp("https://[^\\s\"'<>]+", "g");
const LOGIN_URL_TIMEOUT = 20000;
/**
* Current account status.
* Returns { available, loggedIn, authMethod, email, subscriptionType, orgName, error }.
*/
async function status() {
let binary;
try {
binary = await cli.resolveBinary();
} catch (err) {
return { available: false, loggedIn: false, error: String(err.message || err) };
}
const result = await runCommand(binary, ["auth", "status", "--json"], {
env: cli.environment(),
timeout: 15000,
});
if (result.status !== 0 && !result.stdout.trim()) {
return {
available: true,
loggedIn: false,
error: result.stderr.trim() || `\`claude auth status\` exited with ${result.status}`,
};
}
try {
const parsed = JSON.parse(result.stdout.trim());
return Object.assign({ available: true, usingStoredKey: !!cli.storedApiKey() }, parsed);
} catch (err) {
return {
available: true,
loggedIn: false,
error: "Could not read the account status returned by Claude Code.",
};
}
}
/** A short line for the sidebar, e.g. "[email protected] · Pro". */
function describe(state) {
if (!state) return "Checking…";
if (!state.available) return "Claude Code not found";
if (state.error && !state.loggedIn) return "Signed out";
if (!state.loggedIn) return "Signed out";
const parts = [];
if (state.email) parts.push(state.email);
else if (state.authMethod === "apiKey") parts.push("API key");
if (state.subscriptionType) {
parts.push(state.subscriptionType.charAt(0).toUpperCase() + state.subscriptionType.slice(1));
} else if (state.authMethod) {
parts.push(state.authMethod);
}
return parts.join(" · ") || "Signed in";
}
/**
* Run `claude auth login`, opening the authorization URL in the browser and
* relaying the pasted code back to the CLI.
*
* Resolves true when the CLI exits successfully.
*/
async function signIn(options = {}) {
const binary = await cli.resolveBinary();
const args = ["auth", "login", options.console ? "--console" : "--claudeai"];
const process = new Process(binary, {
args: args,
cwd: nova.workspace.path || nova.environment["HOME"],
env: cli.environment(),
stdio: "pipe",
});
return new Promise((resolve, reject) => {
let writer = null;
let sawURL = false;
let finished = false;
let output = "";
const finish = (fn, value) => {
if (finished) return;
finished = true;
clearTimeout(urlTimer);
fn(value);
};
const handleLine = (line) => {
output += line;
log("auth login:", line.trim());
if (sawURL) return;
const matches = String(line).match(URL_PATTERN);
if (!matches || matches.length === 0) return;
const url = matches[0].replace(/[.,)\]]+$/, "");
if (!/oauth|authorize|login/i.test(url)) return;
sawURL = true;
nova.openURL(url);
promptForCode(url);
};
const promptForCode = (url) => {
nova.workspace.showInputPanel(
"Finish signing in to Claude in your browser, then paste the code it gives you.",
{
label: "Authorization code",
placeholder: "Paste code here",
prompt: "Sign In",
},
(value) => {
if (value === null) {
try {
process.terminate();
} catch (_) {
/* already gone */
}
finish(resolve, false);
return;
}
try {
if (!writer) writer = processWriter(process);
writer.write(value.trim() + "\n");
} catch (err) {
finish(reject, new Error("Could not send the code to Claude Code: " + err));
}
}
);
};
const urlTimer = setTimeout(() => {
if (sawURL || finished) return;
try {
process.terminate();
} catch (_) {
/* already gone */
}
finish(
reject,
new Error(
"Claude Code did not offer a sign-in link. Run `claude auth login` in a terminal to sign in."
)
);
}, LOGIN_URL_TIMEOUT);
process.onStdout(handleLine);
process.onStderr(handleLine);
process.onDidExit((code) => {
if (code === 0) {
finish(resolve, true);
} else {
finish(
reject,
new Error(
(output.trim().split("\n").pop() || "Sign-in failed.") +
` (claude auth login exited with ${code})`
)
);
}
});
try {
process.start();
} catch (err) {
finish(reject, err);
}
});
}
async function signOut() {
const binary = await cli.resolveBinary();
const result = await runCommand(binary, ["auth", "logout"], {
env: cli.environment(),
timeout: 15000,
});
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `\`claude auth logout\` exited with ${result.status}`);
}
return true;
}
/**
* Fallback path: open a terminal window running `claude auth login`, for
* environments where the piped flow cannot complete (SSO, device prompts).
*/
async function signInViaTerminal() {
const binary = await cli.resolveBinary();
const dir = nova.extension.globalStoragePath;
try {
nova.fs.mkdir(dir);
} catch (_) {
/* already exists */
}
const scriptPath = nova.path.join(dir, "sign-in-to-claude.command");
const file = nova.fs.open(scriptPath, "w");
file.write("#!/bin/sh\n");
file.write("# Opened by the Claude extension for Nova.\n");
file.write(`exec ${JSON.stringify(binary)} auth login\n`);
file.close();
await runCommand("/bin/chmod", ["+x", scriptPath], { timeout: 5000 });
const result = await runCommand("/usr/bin/open", [scriptPath], { timeout: 5000 });
if (result.status !== 0) {
warn("could not open a terminal:", result.stderr);
throw new Error("Could not open a terminal window. Run `claude auth login` yourself to sign in.");
}
return true;
}
module.exports = {
status,
describe,
signIn,
signOut,
signInViaTerminal,
};
+156
View File
@@ -0,0 +1,156 @@
//
// cli.js — locating the Claude Code executable and building its environment.
//
const { conf, log, warn, runCommand } = require("./util.js");
const KEYCHAIN_SERVICE = "unsupervised.claudenova";
const KEYCHAIN_ACCOUNT = "ANTHROPIC_API_KEY";
// Standard install locations, in the order Claude Code's own installer prefers.
const CANDIDATE_PATHS = [
"/.local/bin/claude",
"/.claude/local/claude",
];
const CANDIDATE_ABSOLUTE = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
];
let cachedBinary = null;
function isExecutable(path) {
try {
return nova.fs.access(path, nova.fs.X_OK);
} catch (_) {
return false;
}
}
/** Ask the user's login shell where `claude` lives — catches nvm, mise, asdf, etc. */
async function resolveViaLoginShell() {
const shell = nova.environment["SHELL"] || "/bin/zsh";
const result = await runCommand(shell, ["-l", "-c", "command -v claude"], { timeout: 5000 });
if (result.status === 0) {
const path = result.stdout.trim().split("\n").pop();
if (path && path.startsWith("/") && isExecutable(path)) {
return path;
}
}
return null;
}
/**
* Find the `claude` executable.
* Resolution order: configured path, home-relative installs, system paths, login shell.
* The result is cached until `forgetBinary()` is called.
*/
async function resolveBinary() {
if (cachedBinary) return cachedBinary;
const configured = conf("claudenova.binaryPath", "").trim();
if (configured) {
if (isExecutable(configured)) {
cachedBinary = configured;
return cachedBinary;
}
throw new Error(
`The configured Claude Code executable is not runnable: ${configured}`
);
}
const home = nova.environment["HOME"] || "";
const candidates = CANDIDATE_PATHS.map((p) => home + p).concat(CANDIDATE_ABSOLUTE);
for (const candidate of candidates) {
if (isExecutable(candidate)) {
cachedBinary = candidate;
log("resolved claude binary:", candidate);
return cachedBinary;
}
}
const fromShell = await resolveViaLoginShell();
if (fromShell) {
cachedBinary = fromShell;
log("resolved claude binary via login shell:", fromShell);
return cachedBinary;
}
throw new Error(
"Claude Code was not found. Install it from https://claude.com/product/claude-code, " +
"or set the executable path in the extension's settings."
);
}
function forgetBinary() {
cachedBinary = null;
}
/** The API key the user stored in the keychain, if any. */
function storedApiKey() {
try {
return nova.credentials.getPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
} catch (err) {
warn("could not read the keychain:", err);
return null;
}
}
function setStoredApiKey(key) {
nova.credentials.setPassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, key);
}
function clearStoredApiKey() {
try {
nova.credentials.removePassword(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
} catch (err) {
warn("could not clear the keychain entry:", err);
}
}
/**
* Environment for spawned `claude` processes: Nova's task environment, plus a
* stored API key when the user supplied one instead of signing in.
*/
function environment(extra = {}) {
const env = Object.assign({}, nova.environment, extra);
const key = storedApiKey();
if (key) {
env["ANTHROPIC_API_KEY"] = key;
}
// Nova's environment can be sparse; make sure the CLI can find its own tooling.
if (!env["PATH"]) {
env["PATH"] = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin";
}
if (!env["HOME"] && nova.environment["HOME"]) {
env["HOME"] = nova.environment["HOME"];
}
return env;
}
async function version() {
const binary = await resolveBinary();
const result = await runCommand(binary, ["--version"], {
env: environment(),
timeout: 10000,
});
return result.status === 0 ? result.stdout.trim() : null;
}
module.exports = {
resolveBinary,
forgetBinary,
environment,
version,
storedApiKey,
setStoredApiKey,
clearStoredApiKey,
KEYCHAIN_SERVICE,
KEYCHAIN_ACCOUNT,
};
+386
View File
@@ -0,0 +1,386 @@
//
// 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
@@ -0,0 +1,299 @@
//
// 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,
};
+744
View File
@@ -0,0 +1,744 @@
//
// main.js — extension entry point.
//
// Owns the lifecycle: one Claude Code process per window, a session model fed
// from its output, and the views and commands that surface it.
//
const { conf, log, warn, relativize, oneLine } = require("./util.js");
const cli = require("./cli.js");
const auth = require("./auth.js");
const diff = require("./diff.js");
const store = require("./sessions-store.js");
const { ClaudeClient } = require("./client.js");
const { Session } = require("./session.js");
const { Transcript } = require("./transcript.js");
const { Sidebar } = require("./sidebar.js");
const { PermissionBroker } = require("./permissions.js");
const MODELS = [
["", "Default (from your Claude Code settings)"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
["fable", "Fable"],
];
const PERMISSION_MODES = [
["manual", "Manual — ask before each tool"],
["acceptEdits", "Accept Edits — auto-approve file edits"],
["plan", "Plan — research only, no changes"],
["auto", "Auto — Claude decides what needs asking"],
["bypassPermissions", "Bypass — approve everything (dangerous)"],
];
class Controller {
constructor() {
this.session = new Session();
this.transcript = new Transcript(this.session);
this.broker = new PermissionBroker(this.session, this.transcript);
this.client = null;
this.authState = null;
this.sidebar = null;
this.starting = null;
}
// -- lifecycle ---------------------------------------------------------
activate() {
this.transcript.showThinking = conf("claudenova.showThinking", true) !== false;
const controller = this;
this.sidebar = new Sidebar({
session: this.session,
get authState() {
return controller.authState;
},
describeAuth: () => auth.describe(this.authState),
configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")),
configuredPermissionMode: () =>
this.labelFor(PERMISSION_MODES, conf("claudenova.permissionMode", "manual")),
});
this.session.onChange.on(() => {
this.sidebar.reload();
this.transcript.update();
});
this.registerCommands();
this.watchConfig();
this.refreshAuth().catch((err) => log("initial auth check failed:", err));
}
dispose() {
if (this.client) this.client.stop(true);
this.transcript.dispose();
if (this.sidebar) this.sidebar.dispose();
}
labelFor(table, value) {
const row = table.find((entry) => entry[0] === (value || ""));
return row ? row[1] : value;
}
watchConfig() {
const reloadThinking = () => {
this.transcript.showThinking = conf("claudenova.showThinking", true) !== false;
this.transcript.update();
};
nova.subscriptions.add(nova.config.onDidChange("claudenova.showThinking", reloadThinking));
nova.subscriptions.add(
nova.workspace.config.onDidChange("claudenova.showThinking", reloadThinking)
);
nova.subscriptions.add(
nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary())
);
}
// -- account -----------------------------------------------------------
async refreshAuth() {
this.authState = await auth.status();
this.sidebar.reload();
return this.authState;
}
// -- session -----------------------------------------------------------
sessionOptions(overrides = {}) {
const extra = String(conf("claudenova.extraArgs", "")).trim();
return Object.assign(
{
cwd: nova.workspace.path || undefined,
model: conf("claudenova.model", "") || undefined,
effort: conf("claudenova.effort", "") || undefined,
permissionMode: conf("claudenova.permissionMode", "manual"),
streamPartial: conf("claudenova.streamPartial", true) !== false,
extraArgs: extra ? extra.split(/\s+/) : [],
},
overrides
);
}
/** Start a session if one is not already running. */
async ensureSession() {
if (this.client && this.client.isRunning) return this.client;
if (this.starting) return this.starting;
this.starting = this.startSession().finally(() => {
this.starting = null;
});
return this.starting;
}
async startSession(overrides = {}) {
if (this.client) this.client.stop();
this.clearStagedProposals();
this.session.reset();
this.session.setStatus("starting");
const client = new ClaudeClient({
onMessage: (message) => this.session.handleMessage(message),
onPermission: (request, requestId) => this.broker.handle(request, requestId),
onPermissionCancelled: (requestId) => this.broker.cancel(requestId),
onExit: (status, stderr) => this.handleExit(status, stderr),
});
this.client = client;
try {
await client.start(this.sessionOptions(overrides));
} catch (err) {
this.client = null;
this.session.setStatus("error", String(err.message || err));
throw err;
}
this.session.sessionId = client.sessionId;
this.session.setStatus("idle");
if (conf("claudenova.openTranscript", true) !== false) {
await this.transcript.show();
}
return client;
}
/** Remove proposal files left over from a previous session. */
clearStagedProposals() {
const root = nova.path.join(
nova.workspace.path
? nova.extension.workspaceStoragePath
: nova.extension.globalStoragePath,
"proposed"
);
try {
if (!nova.fs.access(root, nova.fs.F_OK)) return;
for (const name of nova.fs.listdir(root)) {
nova.fs.remove(nova.path.join(root, name));
}
} catch (err) {
log("could not clear staged proposals:", err);
}
}
handleExit(status, stderr) {
const wasBusy = this.session.isBusy;
this.session.setStatus("stopped");
for (const approval of this.session.approvals.slice()) {
approval.settled = true;
this.session.removeApproval(approval.requestId);
}
if (status !== 0 && wasBusy) {
const detail = oneLine(stderr, 300) || `Claude Code exited with status ${status}.`;
this.session.addNotice(detail, "error");
nova.workspace.showErrorMessage("Claude Code stopped unexpectedly.\n\n" + detail);
}
this.transcript.flush().catch(() => {
/* the tab may be closed */
});
}
// -- asking ------------------------------------------------------------
async ask(text, context) {
if (!text || !text.trim()) return;
try {
await this.ensureSession();
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
return;
}
this.session.addUserTurn(text, context ? context.label : null);
await this.transcript.show();
try {
this.client.send(context ? context.content(text) : text);
} catch (err) {
this.session.setStatus("error", String(err.message || err));
nova.workspace.showErrorMessage("Could not send your message: " + (err.message || err));
}
}
promptAndAsk(prefill, context) {
nova.workspace.showInputPalette(
context ? `Ask Claude about ${context.label}` : "Ask Claude",
{ placeholder: "What would you like Claude to do?", value: prefill || "" },
(value) => {
if (value === null) return;
this.ask(value, context);
}
);
}
/** Build the context payload for the current editor selection. */
selectionContext(editor) {
if (!editor) return null;
if (conf("claudenova.includeSelection", true) === false) return null;
const range = editor.selectedRange;
if (!range || range.empty) return null;
const document = editor.document;
const path = document.path;
if (!path) return null;
const before = document.getTextInRange(new Range(0, range.start));
const startLine = before.split("\n").length;
const selected = document.getTextInRange(range);
const endLine = startLine + selected.split("\n").length - 1;
const label = `${relativize(path)}:${startLine}-${endLine}`;
const syntax = document.syntax || "";
return {
label: label,
content: (question) =>
[
question,
"",
`From \`${relativize(path)}\` lines ${startLine}${endLine}:`,
"```" + syntax,
selected,
"```",
].join("\n"),
};
}
fileContext(editor) {
const path = editor && editor.document && editor.document.path;
if (!path) return null;
return {
label: relativize(path),
content: (question) => `${question}\n\nThe file in question is \`${path}\`.`,
};
}
// -- commands ----------------------------------------------------------
registerCommands() {
const register = (name, callback) => {
nova.subscriptions.add(nova.commands.register(name, callback));
};
register("claudenova.ask", (editor) => {
const context = TextEditor.isTextEditor(editor)
? this.selectionContext(editor)
: this.selectionContext(nova.workspace.activeTextEditor);
this.promptAndAsk("", context);
});
register("claudenova.askAboutSelection", (editor) => {
const context = this.selectionContext(editor);
if (!context) {
nova.workspace.showWarningMessage("Select some code first, then ask Claude about it.");
return;
}
this.promptAndAsk("", context);
});
register("claudenova.addFiles", () => this.commandAddFiles());
register("claudenova.addFileContext", (editor) => {
const context = this.fileContext(editor);
if (!context) {
nova.workspace.showWarningMessage("Save the file first so Claude can read it.");
return;
}
this.promptAndAsk("", context);
});
register("claudenova.plan", (editor) => {
const context = TextEditor.isTextEditor(editor)
? this.selectionContext(editor)
: this.selectionContext(nova.workspace.activeTextEditor);
this.commandPlan(context);
});
register("claudenova.showTranscript", () => {
this.transcript.show().catch((err) => warn("could not open the transcript:", err));
});
register("claudenova.newSession", () => this.commandNewSession());
register("claudenova.resumeSession", () => this.commandResumeSession());
register("claudenova.interrupt", () => this.commandInterrupt());
register("claudenova.endSession", () => this.commandEndSession());
register("claudenova.setModel", () => this.commandSetModel());
register("claudenova.setPermissionMode", () => this.commandSetPermissionMode());
register("claudenova.signIn", () => this.commandSignIn());
register("claudenova.signOut", () => this.commandSignOut());
register("claudenova.authStatus", () => this.commandAuthStatus());
register("claudenova.refreshStatus", () => this.refreshAuth());
register("claudenova.setApiKey", () => this.commandSetApiKey());
register("claudenova.clearApiKey", () => this.commandClearApiKey());
register("claudenova.approvalAction", () => this.commandApprovalAction());
register("claudenova.reviewApproval", () => this.approvalCommand("review"));
register("claudenova.allowApproval", () => this.approvalCommand("allow"));
register("claudenova.allowApprovalAlways", () => this.approvalCommand("always"));
register("claudenova.denyApproval", () => this.approvalCommand("deny"));
register("claudenova.openActivityFile", () => this.commandOpenActivityFile());
register("claudenova.copyActivity", () => this.commandCopyActivity());
register("claudenova.openChangedFile", () => this.commandOpenChangedFile());
register("claudenova.showChangeDiff", () => this.commandShowChangeDiff());
register("claudenova.clearChanges", () => this.session.clearChangedFiles());
}
/**
* Ask in plan mode: Claude researches and proposes, changing nothing until
* you approve the plan. Switches a running session over rather than
* starting a new one, so the existing context carries into the planning.
*/
async commandPlan(context) {
try {
await this.ensureSession();
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
return;
}
if (this.session.permissionMode !== "plan") {
try {
await this.client.setPermissionMode("plan");
this.session.permissionMode = "plan";
this.session.changed();
} catch (err) {
warn("could not switch to plan mode:", err);
nova.workspace.showWarningMessage(
"Could not switch to plan mode: " + (err.message || err)
);
return;
}
}
nova.workspace.showInputPalette(
context ? `Plan with Claude about ${context.label}` : "Plan with Claude",
{ placeholder: "What should Claude plan?" },
(value) => {
if (value === null) return;
this.ask(value, context);
}
);
}
/**
* Pick files (or a directory) to put in front of Claude, with the current
* selection's line range attached when there is one. The editor-menu
* command covers the active file; this covers everything else.
*/
commandAddFiles() {
nova.workspace.showFileChooser(
"Choose files for Claude to work with",
{
prompt: "Add",
allowFiles: true,
allowDirectories: true,
allowMultiple: true,
},
(paths) => {
if (!paths || paths.length === 0) return;
const listed = paths.map((path) => "`" + relativize(path) + "`").join(", ");
const label = paths.length === 1 ? relativize(paths[0]) : `${paths.length} files`;
this.promptAndAsk("", {
label: label,
content: (question) =>
`${question}\n\nWork with ${paths.length === 1 ? "this file" : "these files"}: ${listed}`,
});
}
);
}
async commandNewSession() {
try {
await this.startSession();
this.session.addNotice("New session started.", "info");
} catch (err) {
nova.workspace.showErrorMessage(String(err.message || err));
}
}
commandResumeSession() {
const sessions = store.recentSessions(nova.workspace.path);
if (sessions.length === 0) {
nova.workspace.showInformativeMessage("No previous Claude sessions were found for this project.");
return;
}
const choices = sessions.map((session) => {
const when = session.modifiedAt.toLocaleString();
return `${session.label}${when}`;
});
nova.workspace.showChoicePalette(
choices,
{ placeholder: "Resume a Claude session" },
(choice, index) => {
if (index === null || index === undefined) return;
const picked = sessions[index];
this.startSession({ resume: picked.id })
.then(() => {
this.session.addNotice(`Resumed session ${picked.id.slice(0, 8)}.`, "info");
})
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
}
);
}
async commandInterrupt() {
if (!this.client || !this.client.isRunning) {
nova.workspace.showInformativeMessage("No Claude session is running.");
return;
}
try {
await this.client.interrupt();
this.session.addNotice("Interrupted.", "info");
this.session.setStatus("idle");
} catch (err) {
warn("interrupt failed:", err);
nova.workspace.showWarningMessage("Could not interrupt Claude: " + (err.message || err));
}
}
commandEndSession() {
if (!this.client) {
nova.workspace.showInformativeMessage("No Claude session is running.");
return;
}
this.client.stop();
this.client = null;
this.session.setStatus("stopped");
this.transcript.flush().catch(() => {
/* the tab may be closed */
});
}
commandSetModel() {
const labels = MODELS.map((entry) => entry[1]);
nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => {
if (index === null || index === undefined) return;
const value = MODELS[index][0];
nova.workspace.config.set("claudenova.model", value);
if (this.client && this.client.isRunning) {
this.client
.setModel(value || undefined)
.then(() => {
this.session.model = value || null;
this.session.changed();
})
.catch((err) => warn("could not change the model:", err));
}
});
}
commandSetPermissionMode() {
const labels = PERMISSION_MODES.map((entry) => entry[1]);
nova.workspace.showChoicePalette(labels, { placeholder: "Permission mode" }, (choice, index) => {
if (index === null || index === undefined) return;
const value = PERMISSION_MODES[index][0];
nova.workspace.config.set("claudenova.permissionMode", value);
if (this.client && this.client.isRunning) {
this.client
.setPermissionMode(value)
.then(() => {
this.session.permissionMode = value;
this.session.changed();
})
.catch((err) => warn("could not change the permission mode:", err));
}
});
}
// -- account commands --------------------------------------------------
commandSignIn() {
nova.workspace.showActionPanel(
"How would you like to sign in to Claude?",
{
buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"],
},
(index) => {
if (index === null || index === 3) return;
if (index === 2) {
auth.signInViaTerminal()
.then(() => {
nova.workspace.showInformativeMessage(
"Finish signing in in the terminal window, then use Refresh in the Claude sidebar."
);
})
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
return;
}
auth.signIn({ console: index === 1 })
.then(async (ok) => {
if (!ok) return;
await this.refreshAuth();
nova.workspace.showInformativeMessage("Signed in to Claude as " + auth.describe(this.authState));
})
.catch((err) => {
nova.workspace.showErrorMessage(
String(err.message || err) +
"\n\nYou can also sign in by running `claude auth login` in a terminal."
);
});
}
);
}
commandSignOut() {
auth.signOut()
.then(() => this.refreshAuth())
.then(() => nova.workspace.showInformativeMessage("Signed out of Claude."))
.catch((err) => nova.workspace.showErrorMessage(String(err.message || err)));
}
async commandAuthStatus() {
const state = await this.refreshAuth();
const lines = [];
if (!state.available) {
lines.push("Claude Code was not found on this machine.");
if (state.error) lines.push(state.error);
} else if (state.loggedIn) {
lines.push("Signed in to Claude.");
if (state.email) lines.push("Account: " + state.email);
if (state.subscriptionType) lines.push("Plan: " + state.subscriptionType);
if (state.orgName) lines.push("Organization: " + state.orgName);
if (state.authMethod) lines.push("Method: " + state.authMethod);
} else {
lines.push("Not signed in.");
if (state.error) lines.push(state.error);
}
if (cli.storedApiKey()) {
lines.push("");
lines.push("An API key stored in your keychain is being passed to Claude Code.");
}
const version = await cli.version().catch(() => null);
if (version) {
lines.push("");
lines.push("Claude Code " + version);
}
nova.workspace.showInformativeMessage(lines.join("\n"));
}
commandSetApiKey() {
nova.workspace.showInputPanel(
"Store an Anthropic API key in your keychain. Claude Code will use it instead of your signed-in account.",
{ label: "API key", placeholder: "sk-ant-…", secure: true, prompt: "Save" },
(value) => {
if (value === null) return;
const key = value.trim();
if (!key) return;
try {
cli.setStoredApiKey(key);
nova.workspace.showInformativeMessage(
"Saved. Start a new Claude session for it to take effect."
);
this.refreshAuth();
} catch (err) {
nova.workspace.showErrorMessage("Could not save the key: " + (err.message || err));
}
}
);
}
commandClearApiKey() {
cli.clearStoredApiKey();
nova.workspace.showInformativeMessage(
"Removed the stored API key. Start a new Claude session for it to take effect."
);
this.refreshAuth();
}
// -- approval commands -------------------------------------------------
/** An action row under a pending request was activated. */
commandApprovalAction() {
const selection = this.sidebar.approvalsView.selection;
const row = selection && selection[0];
if (!row || !row.approval) return;
this.broker.decide(row.approval, row.id);
}
approvalCommand(choice) {
const selection = this.sidebar.approvalsView.selection;
const selected = selection && selection[0];
// Selecting an action row and using the context menu should still act
// on its parent request.
const approval =
(selected && (selected.approval || (selected.request ? selected : null))) ||
this.session.approvals[0];
if (!approval) {
nova.workspace.showInformativeMessage("Nothing is waiting for approval.");
return;
}
this.broker.decide(approval, choice);
}
// -- activity and changes ----------------------------------------------
commandOpenActivityFile() {
const selection = this.sidebar.activityView.selection;
const entry = selection && selection[0];
if (!entry) return;
if (entry.filePath) {
nova.workspace.openFile(entry.filePath);
} else {
this.commandCopyActivity();
}
}
commandCopyActivity() {
const selection = this.sidebar.activityView.selection;
const entry = selection && selection[0];
if (!entry) return;
const details = [
`${entry.name}${entry.state}`,
"",
JSON.stringify(entry.input, null, 2),
];
if (entry.result) {
details.push("", "Result:", entry.result);
}
nova.clipboard.writeText(details.join("\n"));
nova.workspace.showInformativeMessage("Copied the tool call to the clipboard.");
}
commandOpenChangedFile() {
const selection = this.sidebar.changesView.selection;
for (const change of selection || []) {
nova.workspace.openFile(change.path);
}
}
commandShowChangeDiff() {
const selection = this.sidebar.changesView.selection;
const change = selection && selection[0];
if (!change) return;
const tool = this.session.entries
.filter((entry) => entry.kind === "tool" && entry.filePath === change.path)
.pop();
if (!tool) {
nova.workspace.openFile(change.path);
return;
}
const preview = diff.previewForTool(tool.name, tool.input);
if (preview) {
diff.showPreview(preview);
} else {
nova.workspace.openFile(change.path);
}
}
}
const controller = new Controller();
exports.activate = function () {
log("activating");
try {
controller.activate();
} catch (err) {
warn("activation failed:", err);
nova.workspace.showErrorMessage("The Claude extension failed to start: " + (err.message || err));
}
};
exports.deactivate = function () {
controller.dispose();
};
+938
View File
@@ -0,0 +1,938 @@
//
// 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
@@ -0,0 +1,482 @@
//
// 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,
};
+110
View File
@@ -0,0 +1,110 @@
//
// sessions-store.js — reading Claude Code's own session history.
//
// Claude Code writes one JSONL transcript per session under
// ~/.claude/projects/<slug>/<session-id>.jsonl, where the slug is the project
// directory with path separators and dots flattened to dashes. Listing that
// directory is enough to offer a "resume" picker without shelling out to the
// interactive one.
//
const { log, oneLine } = require("./util.js");
function projectSlug(path) {
return String(path).replace(/[/.]/g, "-");
}
function projectDirectory(workspacePath) {
const home = nova.environment["HOME"];
if (!home || !workspacePath) return null;
return nova.path.join(home, ".claude", "projects", projectSlug(workspacePath));
}
/** First user prompt in a transcript, used as the picker label. */
function firstPrompt(filePath) {
let file = null;
try {
file = nova.fs.open(filePath, "r");
for (let i = 0; i < 40; i++) {
const line = file.readline();
if (!line) break;
let record;
try {
record = JSON.parse(line);
} catch (_) {
continue;
}
if (record.type !== "user" || !record.message) continue;
if (record.isMeta || record.isSynthetic) continue;
const content = record.message.content;
if (typeof content === "string") {
if (content.trim()) return oneLine(content, 70);
} else if (Array.isArray(content)) {
for (const block of content) {
if (block && block.type === "text" && block.text && block.text.trim()) {
return oneLine(block.text, 70);
}
}
}
}
} catch (err) {
log("could not read session transcript:", filePath, err);
} finally {
if (file) {
try {
file.close();
} catch (_) {
/* already closed */
}
}
}
return null;
}
/**
* Recent sessions for a workspace, newest first.
* Returns [{ id, path, modifiedAt, label }].
*/
function recentSessions(workspacePath, limit = 15) {
const directory = projectDirectory(workspacePath);
if (!directory) return [];
let names;
try {
if (!nova.fs.access(directory, nova.fs.R_OK)) return [];
names = nova.fs.listdir(directory);
} catch (err) {
log("no session history:", err);
return [];
}
const sessions = [];
for (const name of names) {
if (!name.endsWith(".jsonl")) continue;
const filePath = nova.path.join(directory, name);
const stats = nova.fs.stat(filePath);
if (!stats || !stats.isFile() || stats.size === 0) continue;
sessions.push({
id: name.slice(0, -".jsonl".length),
path: filePath,
modifiedAt: stats.mtime,
size: stats.size,
});
}
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
const recent = sessions.slice(0, limit);
for (const session of recent) {
session.label = firstPrompt(session.path) || session.id.slice(0, 8);
}
return recent;
}
module.exports = { recentSessions, projectDirectory, projectSlug };
+305
View File
@@ -0,0 +1,305 @@
//
// sidebar.js — the four 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.
//
const {
throttle,
relativize,
basename,
oneLine,
modeLabel,
formatCost,
formatTokens,
} = require("./util.js");
const { friendlyToolName } = require("./session.js");
const { actionsFor } = require("./permissions.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);
if (options.descriptiveText) treeItem.descriptiveText = options.descriptiveText;
if (options.tooltip) treeItem.tooltip = options.tooltip;
if (options.image) treeItem.image = options.image;
if (options.path) treeItem.path = options.path;
if (options.command) treeItem.command = options.command;
if (options.contextValue) treeItem.contextValue = options.contextValue;
if (options.identifier) treeItem.identifier = options.identifier;
return treeItem;
}
class SessionProvider {
constructor(context) {
this.context = context;
}
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,
});
}
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 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",
});
}
case "model":
return item("Model", {
descriptiveText: element.name,
command: "claudenova.setModel",
contextValue: "model",
});
case "mode":
return item("Permissions", {
descriptiveText: element.name,
command: "claudenova.setPermissionMode",
contextValue: "mode",
});
case "usage":
return item("Usage", {
descriptiveText: [element.cost, element.tokens].filter(Boolean).join(" · "),
contextValue: "usage",
});
default:
return item(String(element.kind));
}
}
getParent() {
return null;
}
}
class ApprovalsProvider {
constructor(context) {
this.context = context;
}
getChildren(element) {
if (!element) return this.context.session.approvals.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)
);
}
return [];
}
getTreeItem(element) {
if (element.approval) {
return item(element.label, {
identifier: element.approval.requestId + ":" + element.id,
contextValue: "approval-action",
command: "claudenova.approvalAction",
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;
}
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;
}
}
class Sidebar {
/**
* @param context an object exposing `session`, `authState`, `describeAuth()`,
* `configuredModel()` and `configuredPermissionMode()`.
*/
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.views = [this.sessionView, this.approvalsView, this.activityView, this.changesView];
this.reload = throttle(() => {
for (const view of this.views) {
view.reload().catch(() => {
/* the view may not be visible yet */
});
}
}, RELOAD_INTERVAL);
}
dispose() {
this.reload.cancel();
for (const view of this.views) {
try {
view.dispose();
} catch (_) {
/* already disposed */
}
}
}
}
module.exports = { Sidebar };
+350
View File
@@ -0,0 +1,350 @@
//
// 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 };
+286
View File
@@ -0,0 +1,286 @@
//
// util.js — small helpers shared across the extension.
//
const LOG_PREFIX = "[claude]";
/** Log only when the extension is running in development mode. */
function log(...args) {
if (nova.inDevMode()) {
console.log(LOG_PREFIX, ...args);
}
}
function warn(...args) {
console.warn(LOG_PREFIX, ...args);
}
/** Read a config key, preferring the workspace value when it is set. */
function conf(key, fallback) {
let value = nova.workspace.config.get(key);
if (value === null || value === undefined || value === "") {
value = nova.config.get(key);
}
if (value === null || value === undefined || value === "") {
return fallback;
}
return value;
}
// The CLI reports "default" for the mode its own flag calls "manual".
const PERMISSION_MODE_LABELS = {
default: "Ask each time",
manual: "Ask each time",
acceptEdits: "Accept edits",
plan: "Plan only",
auto: "Auto",
dontAsk: "Never ask",
bypassPermissions: "Bypass (dangerous)",
};
function modeLabel(mode) {
if (!mode) return "";
return PERMISSION_MODE_LABELS[mode] || mode;
}
/** Collapse whitespace and cut to `max` characters with an ellipsis. */
function oneLine(text, max = 80) {
if (!text) return "";
const flat = String(text).replace(/\s+/g, " ").trim();
return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
}
/** Strip ANSI escapes — the CLI may include them in human-readable reasons. */
const ANSI_PATTERN = new RegExp("\\[[0-9;]*[A-Za-z]", "g");
function stripAnsi(text) {
if (!text) return "";
return String(text).replace(ANSI_PATTERN, "");
}
/** Path relative to the workspace, when it is inside one. */
function relativize(path) {
if (!path) return "";
if (nova.workspace.path && path.startsWith(nova.workspace.path)) {
const rel = path.slice(nova.workspace.path.length).replace(/^\//, "");
return rel || nova.path.basename(path);
}
return path;
}
function basename(path) {
if (!path) return "";
return nova.path.basename(path);
}
function formatCost(usd) {
if (typeof usd !== "number" || Number.isNaN(usd)) return null;
if (usd === 0) return "$0.00";
if (usd < 0.01) return "<$0.01";
return "$" + usd.toFixed(2);
}
function formatTokens(n) {
if (typeof n !== "number" || Number.isNaN(n)) return null;
if (n < 1000) return String(n);
if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + "k";
return (n / 1000000).toFixed(1) + "M";
}
function formatDuration(ms) {
if (typeof ms !== "number" || Number.isNaN(ms)) return null;
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + "s";
const m = Math.floor(s / 60);
return `${m}m ${Math.round(s - m * 60)}s`;
}
/**
* Trailing-edge throttle: coalesces bursts into one call every `delay` ms.
* The returned function has `.flush()` to run a pending call immediately.
*/
function throttle(fn, delay) {
let timer = null;
let pending = false;
let lastArgs = null;
const invoke = (args) => {
fn(...(args || []));
timer = setTimeout(() => {
timer = null;
if (pending) {
pending = false;
invoke(lastArgs);
}
}, delay);
};
const throttled = (...args) => {
lastArgs = args;
if (timer === null) {
invoke(args);
} else {
pending = true;
}
};
throttled.flush = () => {
if (!pending) return;
pending = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
fn(...(lastArgs || []));
};
throttled.cancel = () => {
pending = false;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
return throttled;
}
/** Minimal event emitter — keeps subscription ordering obvious. */
class Signal {
constructor() {
this.handlers = [];
}
on(handler) {
this.handlers.push(handler);
return {
dispose: () => {
const i = this.handlers.indexOf(handler);
if (i >= 0) this.handlers.splice(i, 1);
},
};
}
emit(...args) {
for (const handler of this.handlers.slice()) {
try {
handler(...args);
} catch (err) {
warn("signal handler failed:", err);
}
}
}
}
/**
* Run a command to completion and collect its output.
* Resolves with { status, stdout, stderr } it never rejects on a non-zero exit.
*/
function runCommand(command, args, options = {}) {
return new Promise((resolve) => {
let process;
try {
process = new Process(command, {
args: args,
cwd: options.cwd || nova.workspace.path || nova.extension.globalStoragePath,
env: options.env || {},
stdio: "pipe",
shell: options.shell === true,
});
} catch (err) {
resolve({ status: -1, stdout: "", stderr: String(err) });
return;
}
const out = [];
const err = [];
let settled = false;
const settle = (status, extraErr) => {
if (settled) return;
settled = true;
resolve({
status: status,
stdout: out.join(""),
stderr: err.join("") + (extraErr || ""),
});
};
process.onStdout((line) => out.push(line));
process.onStderr((line) => err.push(line));
process.onDidExit((status) => settle(status));
if (options.timeout) {
setTimeout(() => {
if (settled) return;
try {
process.terminate();
} catch (_) {
/* already gone */
}
settle(-1, "\n[timed out]");
}, options.timeout);
}
try {
process.start();
} catch (e) {
settle(-1, String(e));
}
});
}
/**
* A writer for a process's stdin.
* Nova exposes stdin as a WritableStream that also has a convenience `write()`;
* fall back to a stream writer when it does not.
*/
function processWriter(process) {
const stdin = process.stdin;
if (!stdin) {
throw new Error("Process was started without a writable stdin.");
}
if (typeof stdin.write === "function") {
return {
write: (text) => stdin.write(text),
close: () => {
try {
if (typeof stdin.close === "function") stdin.close();
} catch (_) {
/* already closed */
}
},
};
}
const writer = stdin.getWriter();
return {
write: (text) => writer.write(text),
close: () => {
try {
writer.close();
} catch (_) {
/* already closed */
}
},
};
}
module.exports = {
log,
warn,
conf,
processWriter,
modeLabel,
oneLine,
stripAnsi,
relativize,
basename,
formatCost,
formatTokens,
formatDuration,
throttle,
Signal,
runCommand,
};
+29
View File
@@ -0,0 +1,29 @@
# 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.
| 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` |
| [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** | — |
## The one-paragraph 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.
+47
View File
@@ -0,0 +1,47 @@
# Authentication
**Status:** Implemented · **Code:** `Scripts/auth.js`, `Scripts/cli.js`
The extension never implements an OAuth flow and never sees credentials. It
drives Claude Code's own commands.
> **Policy constraint.** Anthropic's Agent SDK terms state that third-party
> developers may not offer claude.ai login in their products. Delegating to
> `claude auth login` is the compliant path *and* the robust one — credentials
> land where Claude Code expects them. Do not reimplement the PKCE flow here.
## Status
`claude auth status --json`
```json
{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty",
"email":"…","orgId":"…","orgName":"…","subscriptionType":"pro"}
```
`describe(state)` renders the sidebar's one-liner ("you@example.com · Pro").
## Sign-in
`claude auth login [--claudeai | --console]` is spawned with piped stdio. The
extension scans stdout/stderr for an authorization URL, opens it with
`nova.openURL`, then prompts for the code and writes it to the child's stdin.
If no URL appears within 20s the attempt is abandoned with a message pointing at
the terminal.
`signInViaTerminal()` is the fallback for SSO or device prompts: it writes a
`sign-in-to-claude.command` script to global storage, `chmod +x`, and `open`s it
so a real terminal runs `claude auth login`.
## API key alternative
Stored with `nova.credentials` (service `unsupervised.claudenova`, account
`ANTHROPIC_API_KEY`) and injected into the spawned process environment by
`cli.environment()`, where it takes precedence over the signed-in account.
## Gotchas
- `cli.environment()` reads the keychain on every spawn. Cheap, but it means
keychain failures must be non-fatal — they are logged and ignored.
- Nova's task environment can be sparse; `environment()` fills in a default
`PATH` and `HOME` so the CLI can find its own tooling.
+40
View File
@@ -0,0 +1,40 @@
# 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
@@ -0,0 +1,60 @@
# 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`.
+122
View File
@@ -0,0 +1,122 @@
# IDE bridge
**Status: Proposed — not built.** This is a design note, not a description of
shipped behaviour.
> 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.
## 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:
- 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.
## How discovery works
1. Serve WebSocket on a random port, bound to loopback.
2. Write `~/.claude/ide/<port>.lock` (or `$CLAUDE_CONFIG_DIR/ide/`):
```json
{
"pid": 1234,
"workspaceFolders": ["/path/to/project"],
"ideName": "Nova",
"transport": "ws",
"authToken": "<32-char lowercase hex, 128 bits from a CSPRNG>"
}
```
3. Export `CLAUDE_CODE_SSE_PORT=<port>` and `ENABLE_IDE_INTEGRATION=true` to the
terminal where `claude` runs.
4. Claude connects and must present
`x-claude-code-ide-authorization: <authToken>` during the handshake; the
server validates it against the lock file.
Messages are JSON-RPC 2.0 over RFC 6455 WebSocket.
## MCP tools to implement
| Tool | Nova feasibility |
| --- | --- |
| `openFile` | ✅ `nova.workspace.openFile(uri, {line, column})` |
| `getCurrentSelection` | ✅ `activeTextEditor.selectedRange` |
| `getLatestSelection` | ✅ track via `onDidChangeSelection` |
| `getOpenEditors` | ✅ `nova.workspace.textEditors` |
| `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. |
| `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. |
| `executeCode` | ❌ No notebook/Jupyter API |
Returning a clear "unsupported" for the last four is fine — the VS Code
extension's surface is a superset of what any given editor can do.
## The Nova-shaped problem
**Nova's JS runtime has no WebSocket** (`fetch`, Streams, `TextEncoder` yes;
WebSocket no). So the server cannot live in the extension. It needs a **Node
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.
```
Nova extension ──jsonrpc/stdio──> node sidecar ──ws──> claude (terminal)
│ │
└─ Nova API (editors, selection) ┘ ~/.claude/ide/<port>.lock
```
## The terminal-tab angle
Nova has a built-in terminal, but **no extension API to create one or send text
to it** — commands can only be placed in the `editor`, `extensions`, `text` and
`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.
If Panic ever exposes a terminal API, the only change is automating that last
step.
## Open questions
1. Does the CLI require the `ideName` to be on an allowlist, or is any string
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?
4. Lock-file lifecycle: who cleans up after a crash? (`pid` is in the file
precisely so stale entries can be detected.)
## Effort
The WebSocket server, JSON-RPC plumbing, lock file and handshake are a
well-defined chunk; the tool implementations are mostly one-liners against the
Nova API. The risk is not the code — it is that the protocol is reverse
engineered and unversioned, so it can change under us. claudecode.nvim tracking
it successfully is good evidence it is stable enough in practice.
+69
View File
@@ -0,0 +1,69 @@
# 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
@@ -0,0 +1,47 @@
# 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
@@ -0,0 +1,39 @@
# 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
@@ -0,0 +1,94 @@
# 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()`.
+42
View File
@@ -0,0 +1,42 @@
# 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
@@ -0,0 +1,89 @@
# 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
@@ -0,0 +1,45 @@
# 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.
+240 -3
View File
@@ -1,8 +1,245 @@
{ {
"identifier": "unsupervised.claudenova", "identifier": "unsupervised.claudenova",
"name": "claude-nova", "name": "Claude",
"organization": "Unsupervised", "organization": "Unsupervised",
"description": "Lorem ipsum, dolor sit amet.", "description": "Claude Code in Nova — a chat sidebar, a live transcript, and tool approvals you review in the editor.",
"version": "1.0", "version": "1.0",
"categories": [] "min_runtime": "10",
"categories": ["sidebars", "commands"],
"license": "MIT",
"repository": "https://git.unsupervised.studio/Unsupervised/claude-nova",
"bugs": "https://git.unsupervised.studio/Unsupervised/claude-nova/issues",
"main": "main.js",
"entitlements": {
"process": true,
"filesystem": "readwrite",
"clipboard": true
},
"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": "Resume Session…", "command": "claudenova.resumeSession"},
{"title": "Stop Claude", "command": "claudenova.interrupt"},
{"title": "End Session", "command": "claudenova.endSession"},
{"separator": true},
{"title": "Set Model…", "command": "claudenova.setModel"},
{"title": "Set Permission Mode…", "command": "claudenova.setPermissionMode"},
{"separator": true},
{"title": "Sign In…", "command": "claudenova.signIn"},
{"title": "Sign Out", "command": "claudenova.signOut"},
{"title": "Show Account Status", "command": "claudenova.authStatus"},
{"title": "Set API Key…", "command": "claudenova.setApiKey"},
{"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"}
]
},
"config": [
{
"key": "claudenova.binaryPath",
"title": "Claude Code executable",
"description": "Path to the `claude` executable. Leave blank to search PATH and the standard install locations.",
"type": "path",
"placeholder": "claude"
},
{
"key": "claudenova.model",
"title": "Model",
"description": "Model alias passed to Claude Code. “Default” uses whatever your Claude Code settings specify.",
"type": "enum",
"values": [
["", "Default"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
["fable", "Fable"]
],
"default": ""
},
{
"key": "claudenova.effort",
"title": "Effort",
"description": "How much thinking and exploration Claude does per turn.",
"type": "enum",
"values": [
["", "Default"],
["low", "Low"],
["medium", "Medium"],
["high", "High"],
["xhigh", "Extra High"],
["max", "Max"]
],
"default": ""
},
{
"key": "claudenova.permissionMode",
"title": "Permission mode",
"description": "How tool use is approved. “Manual” asks you before every tool that needs permission.",
"type": "enum",
"values": [
["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
},
{
"key": "claudenova.extraArgs",
"title": "Additional CLI arguments",
"description": "Space-separated arguments appended to the `claude` command line. For advanced use.",
"type": "string",
"placeholder": "--add-dir ../shared"
}
],
"configWorkspace": [
{
"key": "claudenova.model",
"title": "Model",
"type": "enum",
"values": [
["", "Inherit from global settings"],
["opus", "Opus"],
["sonnet", "Sonnet"],
["haiku", "Haiku"],
["fable", "Fable"]
],
"default": ""
},
{
"key": "claudenova.permissionMode",
"title": "Permission mode",
"type": "enum",
"values": [
["", "Inherit from global settings"],
["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": ""
}
],
"sidebars": [
{
"id": "claude",
"name": "Claude",
"smallImage": "claude-small",
"largeImage": "claude-large",
"sections": [
{
"id": "claude.session",
"name": "Session",
"allowMultiple": false,
"placeholderText": "No session yet.\nPress New Session to start one.",
"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": "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"}
]
},
{
"id": "claude.approvals",
"name": "Needs Approval",
"allowMultiple": false,
"placeholderText": "Nothing waiting on you.",
"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"}
],
"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"},
{"separator": true},
{"title": "Clear List", "command": "claudenova.clearChanges"}
]
}
]
}
]
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB