diff --git a/CHANGELOG.md b/CHANGELOG.md index cc688d6..311759e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ ## 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9f4386 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/Images/claude-large/claude-large.png b/Images/claude-large/claude-large.png new file mode 100644 index 0000000..ff69600 Binary files /dev/null and b/Images/claude-large/claude-large.png differ diff --git a/Images/claude-large/claude-large@2x.png b/Images/claude-large/claude-large@2x.png new file mode 100644 index 0000000..aa1a199 Binary files /dev/null and b/Images/claude-large/claude-large@2x.png differ diff --git a/Images/claude-large/metadata.json b/Images/claude-large/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/claude-large/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/claude-placeholder/claude-placeholder.png b/Images/claude-placeholder/claude-placeholder.png new file mode 100644 index 0000000..becc3e0 Binary files /dev/null and b/Images/claude-placeholder/claude-placeholder.png differ diff --git a/Images/claude-placeholder/claude-placeholder@2x.png b/Images/claude-placeholder/claude-placeholder@2x.png new file mode 100644 index 0000000..6d679c3 Binary files /dev/null and b/Images/claude-placeholder/claude-placeholder@2x.png differ diff --git a/Images/claude-placeholder/metadata.json b/Images/claude-placeholder/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/claude-placeholder/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/Images/claude-small/claude-small.png b/Images/claude-small/claude-small.png new file mode 100644 index 0000000..edcb420 Binary files /dev/null and b/Images/claude-small/claude-small.png differ diff --git a/Images/claude-small/claude-small@2x.png b/Images/claude-small/claude-small@2x.png new file mode 100644 index 0000000..c12247e Binary files /dev/null and b/Images/claude-small/claude-small@2x.png differ diff --git a/Images/claude-small/metadata.json b/Images/claude-small/metadata.json new file mode 100644 index 0000000..e894d75 --- /dev/null +++ b/Images/claude-small/metadata.json @@ -0,0 +1,3 @@ +{ + "template": true +} diff --git a/README.md b/README.md index e69de29..3e71d1a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Scripts/auth.js b/Scripts/auth.js new file mode 100644 index 0000000..9b29902 --- /dev/null +++ b/Scripts/auth.js @@ -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. "james@example.com · 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, +}; diff --git a/Scripts/cli.js b/Scripts/cli.js new file mode 100644 index 0000000..8acbeb3 --- /dev/null +++ b/Scripts/cli.js @@ -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, +}; diff --git a/Scripts/client.js b/Scripts/client.js new file mode 100644 index 0000000..493f545 --- /dev/null +++ b/Scripts/client.js @@ -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 }; diff --git a/Scripts/diff.js b/Scripts/diff.js new file mode 100644 index 0000000..d8a4761 --- /dev/null +++ b/Scripts/diff.js @@ -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, +}; diff --git a/Scripts/main.js b/Scripts/main.js new file mode 100644 index 0000000..d1bfa81 --- /dev/null +++ b/Scripts/main.js @@ -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(); +}; diff --git a/Scripts/permissions.js b/Scripts/permissions.js new file mode 100644 index 0000000..5601f56 --- /dev/null +++ b/Scripts/permissions.js @@ -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 = + "\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("`. + +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. diff --git a/docs/features/questions.md b/docs/features/questions.md new file mode 100644 index 0000000..4af7bd5 --- /dev/null +++ b/docs/features/questions.md @@ -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":{"":""}}} +``` + +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. diff --git a/docs/features/session-history.md b/docs/features/session-history.md new file mode 100644 index 0000000..f77b93e --- /dev/null +++ b/docs/features/session-history.md @@ -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//.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 ` 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. diff --git a/docs/features/session-transport.md b/docs/features/session-transport.md new file mode 100644 index 0000000..ebeb329 --- /dev/null +++ b/docs/features/session-transport.md @@ -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 | --resume ) + [--model ] [--effort ] [--permission-mode ] + [--add-dir ]… [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()`. diff --git a/docs/features/sidebar.md b/docs/features/sidebar.md new file mode 100644 index 0000000..405f77a --- /dev/null +++ b/docs/features/sidebar.md @@ -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`. diff --git a/docs/features/tool-approvals.md b/docs/features/tool-approvals.md new file mode 100644 index 0000000..a6d4c27 --- /dev/null +++ b/docs/features/tool-approvals.md @@ -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 + `/proposed//` 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: , new_string: , replace_all: false}` | +| `MultiEdit` | `{…input, edits:[{old_string: , new_string: }]}` | + +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()`. diff --git a/docs/features/transcript.md b/docs/features/transcript.md new file mode 100644 index 0000000..f4bd191 --- /dev/null +++ b/docs/features/transcript.md @@ -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 + +`/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 `read N lines` — 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. diff --git a/extension.json b/extension.json index 124f13c..0e58edf 100644 --- a/extension.json +++ b/extension.json @@ -1,8 +1,245 @@ { "identifier": "unsupervised.claudenova", - "name": "claude-nova", + "name": "Claude", "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", - "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"} + ] + } + ] + } + ] } diff --git a/extension.png b/extension.png new file mode 100644 index 0000000..5db12e1 Binary files /dev/null and b/extension.png differ