diff --git a/CHANGELOG.md b/CHANGELOG.md index 311759e..77388e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,31 +2,27 @@ Initial release. -- **Chat** — ask Claude from the sidebar, the command palette (⌘⇧L), or the - editor with a selection attached (⌃⌘L). Replies stream into a Markdown - transcript tab. -- **Approvals** — every tool call that needs permission surfaces in the sidebar - and as a notification. File changes open as an editable proposal: save to - apply, close to reject, and anything you change in that tab is what gets - written. A unified diff is a click away. -- **Add Files to Context…** — pick files or folders to work with from a chooser. -- **Plan mode** — plan with ⌘⇧P or by setting the permission mode. Finished - plans open in an editable tab; approve them (choosing whether edits then apply - automatically) or send them back for revision. Feedback can be given by - editing the plan directly — your version becomes the plan of record — by - highlighting a passage to quote it back, or by writing as much as you like in - a scratch tab and choosing Send Feedback. Plans announce themselves with a - notification rather than a modal sheet, so the plan stays readable while you - decide. -- **Questions** — when Claude asks for a decision, the options appear in a - choice palette, with free-text and multi-select answers supported. -- **Sidebar** — session status, account, model, permission mode and cost; - pending approvals with their choices as rows beneath them; a log of tool - activity; and the files Claude changed. -- **Sessions** — start, interrupt, end, or resume any recent session for the - project, including ones started in a terminal. +Claude Code sessions are launched and resumed from Nova's sidebar; the +conversation itself happens in a terminal. + +- **Sessions** — every conversation for the project, newest first, labelled with + its opening prompt. Sessions started in a terminal appear too, because the + list is read from Claude Code's own store. +- **Launching** — copy a command to start a new session or resume any previous + one, in the project directory and with your configured model, effort and + permission mode. Optionally with `--remote-control` for driving the session + from elsewhere. - **Accounts** — sign in with your Claude subscription or an Anthropic Console - account through Claude Code's own OAuth flow, or store an API key in the - macOS keychain. -- Model, effort, and permission mode are configurable globally and per project, - and can be changed mid-session. + account through Claude Code's own OAuth flow, or store an API key in the macOS + keychain. +- **Copy File Reference for Claude** — puts `@path/to/file` on the clipboard for + pasting into a session. + +### Note on the earlier design + +An earlier iteration ran the conversation inside Nova: a driven `claude --print` +session streamed into a Markdown tab, with tool approvals, plan review and +question answering in Nova panels. It was removed before release — modal panels +truncated long questions and lost their input whenever you clicked away to check +something, and Claude Code's terminal interface handles all of it better. That +work is preserved in commit `89d1a06` and informs the planned IDE bridge. diff --git a/CLAUDE.md b/CLAUDE.md index e9f4386..e769d38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,14 +6,20 @@ changing a subsystem. ## What this is -A Nova extension that puts Claude Code inside Panic's Nova editor: a sidebar for -the session, a live Markdown transcript, and tool approvals reviewed in the -editor. +A Nova extension that launches and tracks Claude Code sessions for a project. +**Chat happens in a terminal, not in Nova.** The extension prepares launch +commands, lists the project's sessions, and manages the account behind them. -It **drives the user's own `claude` executable** as a subprocess, speaking the -CLI's newline-delimited JSON protocol over stdio. There is no bundled copy of -Claude Code, no Node sidecar, no npm dependencies, and no network access of its -own. +There is no bundled copy of Claude Code, no Node sidecar, no npm dependencies, +and no network access of its own. + +> **The extension used to drive the conversation itself** — `claude --print` over +> the stream-json protocol, rendered into a Markdown tab, with approvals, plan +> review and question answering in Nova panels. That was removed after real use: +> modal panels truncate long questions and lose their input when you click away. +> The implementation is preserved in commit `89d1a06`; its transport notes and +> the editable-proposal flow are the starting point for the IDE bridge. **Do not +> reintroduce panel-based chat.** ## Hard constraints (do not re-derive these) @@ -39,16 +45,20 @@ relative `require("./x.js")` between `Scripts/` files. Type definitions worth having open: `npm pack @types/nova-editor-node` (it is a single `index.d.ts`, and is more precise than the website). -**Claude Code CLI** — see [session transport](docs/features/session-transport.md) -for the full protocol. The three facts that cost the most to find: +**Claude Code CLI** — the facts this extension relies on: -1. `--permission-prompt-tool stdio` is **required** for permission prompts to - reach the host, and is absent from `claude --help`. Without it every tool - needing approval is silently auto-denied. -2. One assistant message id arrives multiple times (per completed content block, - plus streaming deltas). They must be merged, not appended. -3. A `can_use_tool` control request **must always be answered**, including when - the handler throws — the CLI blocks on it and the session hangs otherwise. +1. Sessions are stored as `~/.claude/projects//.jsonl`, where + the slug is the project path with `/` and `.` flattened to `-`. This is the + CLI's own store, so terminal-started sessions appear in the sidebar too. +2. `claude --resume ` resumes from any terminal; `--remote-control` enables + driving a session from elsewhere. Nothing extra is needed to support either — + a launched session is an ordinary CLI session. +3. `claude auth status --json` gives structured account state. + +If you ever need to drive the CLI programmatically again, commit `89d1a06` +documents the stream-json and control protocols in detail — including that +`--permission-prompt-tool stdio` is required and undocumented, without which +every permission-requiring tool is silently auto-denied. **Policy** — do not implement claude.ai OAuth in this extension. Anthropic's Agent SDK terms bar third-party products from offering claude.ai login. Sign-in @@ -60,15 +70,11 @@ shells out to `claude auth login`. See [authentication](docs/features/authentica extension.json manifest: sidebar, commands, config, entitlements Scripts/ main.js Controller: lifecycle, commands, wiring - client.js CLI process + stream-json/control protocol - session.js conversation model (entries, tools, changed files) - permissions.js can_use_tool → approvals, plans, questions, proposals - transcript.js Markdown tab rendering - sidebar.js four TreeView providers - diff.js change reconstruction + unified diff + launch.js building and copying the launch command + sessions-store.js reading ~/.claude/projects + sidebar.js Status and Sessions TreeView providers auth.js claude auth status/login/logout cli.js binary resolution, environment, keychain - sessions-store.js reading ~/.claude/projects for resume util.js conf, throttle, Signal, runCommand, processWriter docs/features/ one file per capability; read before changing one ``` @@ -96,28 +102,29 @@ nova extension activate . # load into Nova for development Logs (`nova.inDevMode()` output) appear in Nova's Extension Console. -**Node harness.** The Nova API is stubbable, so the transport and model can be -driven under Node against the *real* CLI. This caught four real bugs and is the -highest-value testing move available: +**Node harness.** The Nova API is stubbable, so the extension can be exercised +under Node — including against real session files on disk. This caught several +real bugs and is the highest-value testing move available: ```js global.Process = class { /* wrap child_process.spawn; onStdout/onStderr/onDidExit */ }; global.Range = class { constructor(start, end) { this.start = start; this.end = end; } }; global.nova = { environment: process.env, path, fs, workspace, extension, crypto, inDevMode: () => true, /* … */ }; -const { ClaudeClient } = require("./Scripts/client.js"); +const store = require("./Scripts/sessions-store.js"); ``` -Then wire `onPermission` to scripted answers and send a prompt. Stub -`showChoicePalette` / `showActionPanel` / `showInputPanel` / `notifications.add` -to script UI decisions and assert on the resulting `PermissionResult`. +Point `nova.workspace.path` at a directory that really has sessions, stub +`clipboard.writeText` to capture output, and assert on the built command. Stub +`showChoicePalette` / `showActionPanel` / `notifications.add` to script UI. +`nova.fs.open` needs a working `readline()` — session labels depend on it. > The harness scripts from the build session lived in a temporary scratchpad and > are gone. Recreating them in a `Tests/` folder is the obvious next improvement. -**Live tests cost real money** — they run turns on the signed-in account. Use -`--model haiku` and one-line prompts; a full verification pass is a few tens of -cents. +Nothing in the current feature set spends tokens: the extension builds command +strings and reads files. (If you add code that runs turns, use `--model haiku` +and one-line prompts — a verification pass costs a few tens of cents.) Cheap checks that need no CLI: @@ -128,9 +135,12 @@ python3 -c "import json; json.load(open('extension.json'))" ## State -- Not yet committed. Not yet published. `min_runtime` is declared as `10` - (`nova.crypto` is the newest API touched, and it has a fallback). +- `89d1a06` is the in-editor-chat implementation, kept for the IDE bridge. + The terminal-first rewrite on top of it is uncommitted. +- Not published. `min_runtime` is declared as `10`. - `nova extension validate` last passed cleanly on the schema; a later run failed only on network reachability from a sandbox, not on the bundle. +- **Never run inside Nova yet by this project's tooling** — the user has run + earlier builds; verify UI changes in the app. - Icons are generated by a script (a tapered starburst rendered to PNG with a pure-stdlib encoder); regenerate rather than hand-editing if sizes change. diff --git a/README.md b/README.md index 3e71d1a..567690c 100644 --- a/README.md +++ b/README.md @@ -1,232 +1,110 @@ # Claude for Nova -Claude Code inside [Nova](https://nova.app): a sidebar for the session, a live -transcript in an editor tab, and every tool call routed through an approval you -can review before it touches your files. +Launch and resume [Claude Code](https://claude.com/product/claude-code) sessions +for your project from Nova's sidebar. -The extension drives the `claude` executable already installed on your machine, -speaking its streaming JSON protocol over stdio. Your existing sign-in, settings, -`CLAUDE.md`, skills, plugins, and MCP servers all apply — this is the same agent -you get in a terminal, wearing Nova's interface. +Chat happens in a terminal, where Claude Code's own interface handles questions, +plans and long output properly. Nova's job is everything around it: which +sessions exist, how to get back into one, and which account you're on. ## Requirements -- Nova 10 or later (built against Nova 14; not exercised on older versions) -- [Claude Code](https://claude.com/product/claude-code) installed and on disk - (`~/.local/bin/claude`, Homebrew, or a path you set in settings) +- Nova 10 or later (built against Nova 14) +- Claude Code installed (`~/.local/bin/claude`, Homebrew, or a path you set in + settings) ## Getting started 1. Open the **Claude** sidebar (the starburst in the sidebar rail). -2. If the Account row says *Not signed in*, click it and choose how to sign in. -3. Press **⌘⇧L**, or use the ✳︎ button in the sidebar header, and ask for something. +2. If it says *Not signed in*, click it and choose how to sign in. +3. Press **⌘⇧L** — the launch command is copied to your clipboard. +4. Open a terminal in Nova and paste it. -The first prompt starts a session in your project directory. The transcript opens -as a tab and updates as Claude works. - -## Signing in - -Authentication is handled by Claude Code itself — the extension never sees your -credentials. **Sign In** offers three routes: - -| Route | What happens | -| --- | --- | -| Claude Subscription | Runs `claude auth login`, opens the authorization page in your browser, and relays the code back | -| Anthropic Console | The same flow with `--console`, for API-billed accounts | -| Open a Terminal | Runs `claude auth login` in a terminal window — use this for SSO or if the in-app flow stalls | - -You can also store an API key with **Set API Key…**. It goes in the macOS -keychain and is passed to Claude Code as `ANTHROPIC_API_KEY`, taking precedence -over a signed-in account. **Clear Stored API Key** removes it. - -## Approving tool use - -In the default *Manual* permission mode, Claude asks before each tool that needs -permission. Every request appears in **Needs Approval** in the sidebar *and* as a -panel, so dismissing the panel never strands the session — the request waits -until you answer it from the sidebar's context menu. - -### Reviewing a file change - -When Claude wants to edit or create a file, the file **as it would be after the -change** opens in a tab. Review it there, and settle it with ordinary editor -gestures: - -| Gesture | Result | -| --- | --- | -| **⌘S** save | Apply the change | -| **⌘W** close | Reject it | - -The tab is editable, and that is the point: fix the variable name Claude got -wrong, delete the half you don't want, then save — **your** version is what gets -written, not Claude's. A notification carries the same choices, and they appear -as rows under the request in the sidebar; **Show Diff** gives the unified diff if -you'd rather see just what moved. - -Prefer the old quick yes/no? Set *Reviewing file changes* to **Ask in a panel**. - -**Allow for Session** adds an allow rule for that tool for the rest of the -session, so Claude stops asking about it. - -Two tools are their own interaction surface rather than a yes/no gate, and get -purpose-built handling — see *Plan mode* and *Questions* below. - -## Plan mode - -**Plan with Claude… (⌘⇧P)** switches the session into plan mode and prompts you. -Claude researches and proposes but changes nothing. When it has a plan, the plan -opens in a tab and you choose: - -| Choice | What happens | -| --- | --- | -| Approve — auto-accept edits | Plan accepted; the session moves to Accept Edits so Claude can work uninterrupted | -| Approve — ask before each edit | Plan accepted; you keep approving each change | -| Keep Planning… | Send it back for revision; Claude revises and re-presents | -| Show Plan Again | Re-open the plan | - -When a plan is ready you get a **notification**, not a modal sheet — the plan tab -stays scrollable and editable while you decide. The same choices also appear as -rows under the request in **Needs Approval**, so you can dismiss the notification -and take your time. - -### Giving feedback on a plan - -The plan is an ordinary editable document, and there are three ways to respond — -they combine freely: - -- **Edit the plan in its tab.** Rewrite, delete, or add steps directly. When you - approve, your version is sent back and becomes the plan of record — Claude - works from what you wrote, not its own draft. Unsaved edits count. -- **Highlight a passage**, then choose *Keep Planning…*. The selected lines are - quoted back to Claude along with your note, so "this part is wrong" has an - unambiguous referent. -- **Write Feedback…** opens a scratch tab you can type into freely — as many - paragraphs, lists, and code snippets as you want. When you're done, choose - **Send Feedback** in the sidebar. Nova gives extensions no multi-line field of - their own, so the editor itself is the text box. - -If you edit the plan *and* send feedback, your rewrite goes along with it, marked -authoritative. - -Nothing is lost by ignoring the notification: the plan waits in **Needs Approval** -with its choices until you act. Plans are saved by Claude Code under -`~/.claude/plans/`, so you can reopen one later. - -You can also stay in plan mode for a whole session by setting the permission -mode to *Plan*. - -## Questions - -When Claude needs a decision from you it asks a real question, and the options -appear in a Nova choice palette. Pick one — or **Other…** to type your own -answer. Multi-select questions let you toggle several options and then choose -*Done*. Your answer goes straight back to Claude as the tool's result. - -## Permission modes - -Change the mode any time from the Permissions row: - -| Mode | Behaviour | -| --- | --- | -| Manual | Ask before each tool (default) | -| Accept Edits | File edits apply automatically; other tools still ask | -| Plan | Research only — Claude proposes, changes nothing | -| Auto | Claude decides what is worth asking about | -| Bypass | Approve everything. Only in a directory you trust | +That's a normal `claude` session, in your project directory, with whatever model +and permission mode the sidebar shows. ## The sidebar -- **Session** — status, account, model, permission mode, and running cost. - Double-click Model or Permissions to change them mid-session. -- **Needs Approval** — anything waiting on you, with its choices as rows - underneath: Allow / Allow for Session / Review / Deny for an ordinary tool, - or the approve-and-feedback options for a plan. Double-click a row to take it. -- **Activity** — every tool call, newest first. Double-click one that touched a - file to open it. -- **Changed Files** — files Claude actually modified. Double-click to open, or - use **Show Change…** to see the last edit as a diff. +``` +Claude + Account you@example.com · Pro + Model Opus + Permissions Ask each time +Sessions + ▾ fix the parser bug 2h ago + Copy Resume Command + Copy Resume + Remote Control + Reveal Session File + ▸ add a --version flag yesterday +``` + +**Sessions** lists this project's conversations, newest first, labelled with +their opening prompt. Sessions you started in a terminal appear here too — it is +Claude Code's own store (`~/.claude/projects/`), not a separate list. + +Double-click a session to copy its resume command, or expand it for more: + +| Action | What it copies | +| --- | --- | +| Copy Resume Command | `cd && claude --resume ` | +| Copy Resume + Remote Control | The same, plus `--remote-control` | +| Reveal Session File | *(opens the transcript in Finder)* | + +The list refreshes when the section becomes visible, or from the header button. + +## Why the clipboard? + +Nova provides no terminal API to extensions — an extension cannot open a +terminal tab or type into one. So the extension prepares the exact command and +hands it to you; you paste it wherever you want it, including Nova's own +terminal. One paste, and the session is completely ordinary: resumable from any +terminal, and drivable with `--remote-control`. ## Commands | Command | Shortcut | | --- | --- | -| Ask Claude… | ⌘⇧L | -| Plan with Claude… | ⌘⇧P | -| Ask Claude About Selection… (Editor menu) | ⌃⌘L | -| Add File to Claude's Context (Editor menu) | | -| Add Files to Context… | | -| Show Transcript | | -| New Session / Resume Session… | | -| Stop Claude / End Session | | +| New Session… | ⌘⇧L | +| Resume Session… | | +| Refresh Sessions | | +| Copy File Reference for Claude (Editor menu) | | | Set Model… / Set Permission Mode… | | +| Sign In… / Sign Out / Show Account Status | | +| Set API Key… / Clear Stored API Key | | -**Add Files to Context…** opens a file chooser — pick any files or folders in the -project and ask about them together, without opening them first. +**Copy File Reference for Claude** puts `@path/to/file` on the clipboard for +pasting into a session — Claude Code resolves `@` paths against the project. -**Ask Claude About Selection…** sends the file, the line range, and the selected -text with your question. Plain **Ask Claude** does the same automatically when -there is a selection — turn that off with the *Include the editor selection* -setting. +## Signing in -**Resume Session…** lists this project's recent Claude Code sessions, labelled -with their opening prompt, including ones started in a terminal. +Authentication is handled by Claude Code itself; the extension never sees your +credentials. **Sign In** offers your Claude subscription, an Anthropic Console +account, or opening a terminal to run `claude auth login` yourself — use that +last one for SSO. + +You can also store an API key with **Set API Key…**. It goes in the macOS +keychain and is passed to Claude Code as `ANTHROPIC_API_KEY`. ## Settings -Model, effort, permission mode, and the `claude` executable path are set in -Extension Settings; Model and Permission Mode can also be overridden per project -in Project Settings. Other options: +Model, effort, permission mode and the `claude` executable path are set in +Extension Settings; Model and Permission Mode can be overridden per project. +They are applied to the commands the extension builds, so a launched session +matches what the sidebar shows. **Additional CLI arguments** are appended +verbatim. -- **Reviewing file changes** — an editable proposal tab, or a panel -- **Open the transcript automatically** when a session starts -- **Stream text as it is generated** — token-by-token output -- **Show thinking in the transcript** -- **Include the editor selection with prompts** -- **Additional CLI arguments** — appended to the `claude` command line +## What's next -## How it works - -``` -Nova extension ──stdio (stream-json)──> claude --print - │ │ - │ <── assistant / tool / result ───────┤ - ├──── control_response (allow/deny) ────┤ - └──── control_request (interrupt) ─────>┘ -``` - -One `claude` process per window, launched with -`--input-format stream-json --output-format stream-json`. Session messages and -control traffic share the pipe: the CLI raises a `can_use_tool` control request -for each permission prompt, and the extension answers it with your decision. -Questions are answered by returning the choices as `updatedInput`; approving a -plan returns a `setMode` permission update, which is what moves the session out -of plan mode. Interrupts and mid-session model or permission changes travel the -same channel. - -There is no bundled copy of Claude Code, no Node sidecar, and no network access -of its own — the extension only talks to the executable you already trust. - -## Known limits - -- Nova has no scriptable diff viewer, so proposed changes are shown as a unified - diff in a scratch tab rather than in a side-by-side editor. -- Extension sidebars can only contain tree rows — there is no API for a - multi-line field like the one in Nova's own Source Control sidebar. Feedback - is written in a scratch tab instead, and decisions are tree rows. -- Ordinary tool approvals still use a panel, since they are a quick yes/no. -- Questions and plan approvals are handled natively. Any *other* tool that - needs its own interactive prompt is declined with an explanation — run those - from a terminal. -- The transcript is a Markdown document, so it renders text, not widgets. -- Sessions run in the workspace directory. Open a folder in Nova before asking. +The [IDE bridge](docs/features/ide-bridge.md): a Node sidecar that lets the +terminal session see Nova — the current selection, open files, and diffs +reviewed in the editor rather than as terminal text. ## Development ```sh -nova extension validate . # check the bundle +nova extension validate . # check the bundle (makes network calls) nova extension activate . # load it into Nova for development ``` -Extension logs (with `nova.inDevMode()` output) appear in Nova's Extension -Console. Keyboard shortcuts can be rebound in Preferences → Key Bindings if any -of the defaults collide with yours. +See [`CLAUDE.md`](CLAUDE.md) for architecture and constraints, and +[`docs/features/`](docs/features/README.md) for per-feature detail. diff --git a/Scripts/client.js b/Scripts/client.js deleted file mode 100644 index 493f545..0000000 --- a/Scripts/client.js +++ /dev/null @@ -1,386 +0,0 @@ -// -// client.js — drives `claude` in streaming JSON mode. -// -// The CLI speaks newline-delimited JSON on stdio. Two kinds of traffic share -// that channel: -// -// * session messages — system / assistant / user / result / stream_event -// * control traffic — request/response pairs in both directions, used for -// the initialize handshake, permission prompts -// (`can_use_tool`), interrupts and live setting changes -// -// This class owns the process and the framing; everything above it deals in -// parsed messages. -// - -const { log, warn, processWriter } = require("./util.js"); -const cli = require("./cli.js"); - -const CONTROL_TIMEOUT = 60000; - -function uuid() { - const bytes = new Uint8Array(16); - if (nova.crypto && typeof nova.crypto.getRandomValues === "function") { - nova.crypto.getRandomValues(bytes); - } else { - for (let i = 0; i < bytes.length; i++) { - bytes[i] = Math.floor(Math.random() * 256); - } - } - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 - - const hex = []; - for (let i = 0; i < bytes.length; i++) { - hex.push(bytes[i].toString(16).padStart(2, "0")); - } - const s = hex.join(""); - return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`; -} - -class ClaudeClient { - /** - * @param {object} handlers - * onMessage(message) — a session message from the CLI - * onPermission(request) — must resolve to a PermissionResult - * onPermissionCancelled(id) — the CLI withdrew a pending prompt - * onExit(status, stderr) — the process ended - * onError(error) — transport-level failure - */ - constructor(handlers = {}) { - this.handlers = handlers; - this.process = null; - this.writer = null; - this.sessionId = null; - this.running = false; - this.stdoutBuffer = ""; - this.stderrLines = []; - this.pendingControl = new Map(); - this.inFlightPermissions = new Map(); - } - - get isRunning() { - return this.running; - } - - /** - * Build the command line. Kept separate so it can be inspected and tested. - */ - buildArgs(options) { - const args = [ - "--print", - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--verbose", - // Route permission asks over the control channel to `onPermission` - // instead of auto-denying them. - "--permission-prompt-tool", - "stdio", - ]; - - if (options.streamPartial) args.push("--include-partial-messages"); - - if (options.resume) { - args.push("--resume", options.resume); - } else { - args.push("--session-id", options.sessionId); - } - - if (options.model) args.push("--model", options.model); - if (options.effort) args.push("--effort", options.effort); - if (options.permissionMode) args.push("--permission-mode", options.permissionMode); - - for (const dir of options.additionalDirectories || []) { - args.push("--add-dir", dir); - } - - for (const extra of options.extraArgs || []) { - args.push(extra); - } - - return args; - } - - /** Launch the CLI and complete the initialize handshake. */ - async start(options = {}) { - if (this.running) { - throw new Error("A Claude session is already running."); - } - - const binary = await cli.resolveBinary(); - this.sessionId = options.resume || options.sessionId || uuid(); - - const args = this.buildArgs( - Object.assign({}, options, { sessionId: this.sessionId }) - ); - - log("starting:", binary, args.join(" ")); - - this.stdoutBuffer = ""; - this.stderrLines = []; - this.process = new Process(binary, { - args: args, - cwd: options.cwd || nova.workspace.path || nova.environment["HOME"], - env: cli.environment(), - stdio: "pipe", - }); - - this.process.onStdout((chunk) => this._onStdout(chunk)); - this.process.onStderr((chunk) => this._onStderr(chunk)); - this.process.onDidExit((status) => this._onExit(status)); - - this.process.start(); - this.writer = processWriter(this.process); - this.running = true; - - try { - await this._control({ subtype: "initialize" }, 20000); - } catch (err) { - // A failed handshake is worth reporting, but older CLIs still work - // for plain message traffic, so the session continues. - warn("initialize handshake failed:", err.message || err); - } - - return this.sessionId; - } - - /** Send a user turn. `content` is a string or an array of content blocks. */ - send(content) { - this._write({ - type: "user", - message: { - role: "user", - content: content, - }, - parent_tool_use_id: null, - }); - } - - async interrupt() { - return this._control({ subtype: "interrupt" }, 15000); - } - - async setPermissionMode(mode) { - return this._control({ subtype: "set_permission_mode", mode: mode }); - } - - async setModel(model) { - return this._control({ subtype: "set_model", model: model || undefined }); - } - - /** Stop the session. Graceful by default; `force` sends SIGKILL. */ - stop(force = false) { - if (!this.process) return; - - for (const [, pending] of this.pendingControl) { - pending.reject(new Error("The Claude session ended.")); - } - this.pendingControl.clear(); - this.inFlightPermissions.clear(); - - try { - if (this.writer) this.writer.close(); - } catch (_) { - /* already closed */ - } - - try { - if (force) this.process.kill(); - else this.process.terminate(); - } catch (err) { - warn("could not stop the process:", err); - } - - this.running = false; - } - - // -- internals --------------------------------------------------------- - - _write(object) { - if (!this.writer) { - throw new Error("The Claude session is not running."); - } - this.writer.write(JSON.stringify(object) + "\n"); - } - - /** Send a control request and wait for its matching response. */ - _control(request, timeout = CONTROL_TIMEOUT) { - return new Promise((resolve, reject) => { - if (!this.running && request.subtype !== "initialize") { - reject(new Error("The Claude session is not running.")); - return; - } - - const requestId = "nova-" + uuid(); - const timer = setTimeout(() => { - this.pendingControl.delete(requestId); - reject(new Error(`Claude Code did not answer "${request.subtype}" in time.`)); - }, timeout); - - this.pendingControl.set(requestId, { - resolve: (value) => { - clearTimeout(timer); - this.pendingControl.delete(requestId); - resolve(value); - }, - reject: (err) => { - clearTimeout(timer); - this.pendingControl.delete(requestId); - reject(err); - }, - }); - - try { - this._write({ type: "control_request", request_id: requestId, request: request }); - } catch (err) { - const pending = this.pendingControl.get(requestId); - if (pending) pending.reject(err); - } - }); - } - - _onStdout(chunk) { - this.stdoutBuffer += chunk; - - let newline = this.stdoutBuffer.indexOf("\n"); - while (newline >= 0) { - const line = this.stdoutBuffer.slice(0, newline).trim(); - this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); - if (line) this._handleLine(line); - newline = this.stdoutBuffer.indexOf("\n"); - } - } - - _onStderr(chunk) { - const text = String(chunk).trim(); - if (!text) return; - this.stderrLines.push(text); - if (this.stderrLines.length > 200) this.stderrLines.shift(); - log("stderr:", text); - } - - _handleLine(line) { - let message; - try { - message = JSON.parse(line); - } catch (err) { - // Anything that is not JSON is CLI chatter, not protocol traffic. - log("non-JSON output:", line.slice(0, 200)); - return; - } - - switch (message.type) { - case "control_response": - this._handleControlResponse(message.response); - return; - case "control_request": - this._handleControlRequest(message); - return; - case "control_cancel_request": - this._handleControlCancel(message.request_id); - return; - case "keep_alive": - case "transcript_mirror": - return; - default: - break; - } - - if (message.session_id) { - this.sessionId = message.session_id; - } - - if (this.handlers.onMessage) { - this.handlers.onMessage(message); - } - } - - _handleControlResponse(response) { - if (!response || !response.request_id) return; - - const pending = this.pendingControl.get(response.request_id); - if (!pending) return; - - if (response.subtype === "success") { - pending.resolve(response.response); - } else { - pending.reject(new Error(response.error || "Claude Code reported a control error.")); - } - } - - async _handleControlRequest(message) { - const requestId = message.request_id; - const request = message.request || {}; - - try { - let result; - - if (request.subtype === "can_use_tool") { - if (!this.handlers.onPermission) { - throw new Error("No permission handler is registered."); - } - // A handler that throws must still produce an answer: the CLI - // blocks on this request, so silence would hang the session. - this.inFlightPermissions.set(requestId, request); - result = await this.handlers.onPermission(request, requestId); - if (!this.inFlightPermissions.has(requestId)) { - // Cancelled while the user was deciding; the CLI moved on. - return; - } - this.inFlightPermissions.delete(requestId); - result = Object.assign({ toolUseID: request.tool_use_id }, result); - } else { - throw new Error("Unsupported control request subtype: " + request.subtype); - } - - this._write({ - type: "control_response", - response: { subtype: "success", request_id: requestId, response: result }, - }); - } catch (err) { - this.inFlightPermissions.delete(requestId); - try { - this._write({ - type: "control_response", - response: { - subtype: "error", - request_id: requestId, - error: String((err && err.message) || err), - }, - }); - } catch (writeErr) { - warn("could not answer a control request:", writeErr); - } - } - } - - _handleControlCancel(requestId) { - if (this.inFlightPermissions.has(requestId)) { - this.inFlightPermissions.delete(requestId); - if (this.handlers.onPermissionCancelled) { - this.handlers.onPermissionCancelled(requestId); - } - } - } - - _onExit(status) { - this.running = false; - this.writer = null; - - for (const [, pending] of this.pendingControl) { - pending.reject(new Error("The Claude session ended.")); - } - this.pendingControl.clear(); - - const stderr = this.stderrLines.join("\n"); - log("process exited with", status); - - if (this.handlers.onExit) { - this.handlers.onExit(status, stderr); - } - } -} - -module.exports = { ClaudeClient, uuid }; diff --git a/Scripts/diff.js b/Scripts/diff.js deleted file mode 100644 index d8a4761..0000000 --- a/Scripts/diff.js +++ /dev/null @@ -1,299 +0,0 @@ -// -// diff.js — previewing a change before you approve it. -// -// Nova has no scriptable diff viewer, so proposed edits are rendered as a -// unified diff in a scratch tab. That is enough to answer the only question -// that matters at approval time: what exactly is about to change on disk. -// - -const { relativize, warn } = require("./util.js"); - -const CONTEXT_LINES = 3; -const MAX_DIFF_LINES = 400; - -function readFile(path) { - try { - if (!nova.fs.access(path, nova.fs.R_OK)) return null; - const file = nova.fs.open(path, "r"); - const contents = file.readlines().join(""); - file.close(); - return contents; - } catch (err) { - warn("could not read", path, err); - return null; - } -} - -function splitLines(text) { - if (text === null || text === undefined) return []; - const body = String(text); - if (body === "") return []; - // A trailing newline terminates the last line rather than starting a new - // empty one — otherwise every diff carries a phantom blank line. - const lines = body.split("\n"); - if (lines[lines.length - 1] === "") lines.pop(); - return lines; -} - -/** - * Longest-common-subsequence line diff. - * Common prefixes and suffixes are trimmed first, so the quadratic table only - * ever covers the part that actually differs. - */ -function diffLines(beforeLines, afterLines) { - let start = 0; - while ( - start < beforeLines.length && - start < afterLines.length && - beforeLines[start] === afterLines[start] - ) { - start++; - } - - let endBefore = beforeLines.length; - let endAfter = afterLines.length; - while ( - endBefore > start && - endAfter > start && - beforeLines[endBefore - 1] === afterLines[endAfter - 1] - ) { - endBefore--; - endAfter--; - } - - const a = beforeLines.slice(start, endBefore); - const b = afterLines.slice(start, endAfter); - - const ops = []; - for (let i = 0; i < start; i++) ops.push({ type: "equal", text: beforeLines[i] }); - - // Guard the table: very large rewrites are shown as a plain replacement. - if (a.length * b.length > 1000000) { - for (const line of a) ops.push({ type: "delete", text: line }); - for (const line of b) ops.push({ type: "insert", text: line }); - } else { - const table = []; - for (let i = 0; i <= a.length; i++) { - table.push(new Array(b.length + 1).fill(0)); - } - for (let i = a.length - 1; i >= 0; i--) { - for (let j = b.length - 1; j >= 0; j--) { - table[i][j] = - a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]); - } - } - - let i = 0; - let j = 0; - while (i < a.length && j < b.length) { - if (a[i] === b[j]) { - ops.push({ type: "equal", text: a[i] }); - i++; - j++; - } else if (table[i + 1][j] >= table[i][j + 1]) { - ops.push({ type: "delete", text: a[i] }); - i++; - } else { - ops.push({ type: "insert", text: b[j] }); - j++; - } - } - while (i < a.length) ops.push({ type: "delete", text: a[i++] }); - while (j < b.length) ops.push({ type: "insert", text: b[j++] }); - } - - for (let i = endBefore; i < beforeLines.length; i++) { - ops.push({ type: "equal", text: beforeLines[i] }); - } - - return ops; -} - -/** Render ops as a unified diff with limited context. */ -function renderUnified(ops) { - const keep = new Array(ops.length).fill(false); - - for (let i = 0; i < ops.length; i++) { - if (ops[i].type === "equal") continue; - for (let j = Math.max(0, i - CONTEXT_LINES); j <= Math.min(ops.length - 1, i + CONTEXT_LINES); j++) { - keep[j] = true; - } - } - - const lines = []; - let skipping = false; - let shown = 0; - - for (let i = 0; i < ops.length; i++) { - if (!keep[i]) { - if (!skipping) { - lines.push("@@ …"); - skipping = true; - } - continue; - } - skipping = false; - - if (shown >= MAX_DIFF_LINES) { - lines.push(`@@ … diff truncated after ${MAX_DIFF_LINES} lines`); - break; - } - - const op = ops[i]; - const marker = op.type === "insert" ? "+" : op.type === "delete" ? "-" : " "; - lines.push(marker + op.text); - shown++; - } - - return lines.join("\n"); -} - -function counts(ops) { - let additions = 0; - let deletions = 0; - for (const op of ops) { - if (op.type === "insert") additions++; - else if (op.type === "delete") deletions++; - } - return { additions, deletions }; -} - -/** - * Work out what a file would contain if a pending tool call ran. - * Returns { path, before, after, title, exists } or null when the tool does not - * describe a file change we can reconstruct. - */ -function proposedChange(toolName, input) { - if (!input) return null; - - const path = input.file_path; - if (!path) return null; - - if (toolName === "Write") { - const before = readFile(path); - return { - path: path, - before: before, - after: String(input.content || ""), - exists: before !== null, - title: before === null ? `Create ${relativize(path)}` : `Overwrite ${relativize(path)}`, - }; - } - - if (toolName === "Edit" || toolName === "Update") { - if (typeof input.old_string !== "string") return null; - - const before = readFile(path); - if (before === null) { - // The file is not readable — fall back to showing the replacement - // on its own, which still tells the user what Claude intends. - return { - path: path, - before: input.old_string, - after: String(input.new_string || ""), - exists: false, - title: `Edit ${relativize(path)}`, - }; - } - - const after = input.replace_all - ? before.split(input.old_string).join(input.new_string || "") - : before.replace(input.old_string, input.new_string || ""); - - return { - path: path, - before: before, - after: after, - exists: true, - title: `Edit ${relativize(path)}`, - }; - } - - if (toolName === "MultiEdit" && Array.isArray(input.edits)) { - const before = readFile(path); - if (before === null) return null; - - let after = before; - for (const edit of input.edits) { - if (typeof edit.old_string !== "string") continue; - after = edit.replace_all - ? after.split(edit.old_string).join(edit.new_string || "") - : after.replace(edit.old_string, edit.new_string || ""); - } - - return { - path: path, - before: before, - after: after, - exists: true, - title: `Edit ${relativize(path)} (${input.edits.length} changes)`, - }; - } - - return null; -} - -/** - * Build a preview for a pending tool call. - * Returns { title, path, diff, additions, deletions, before, after } or null. - */ -function previewForTool(toolName, input) { - const change = proposedChange(toolName, input); - if (!change) return null; - - const ops = diffLines(splitLines(change.before), splitLines(change.after)); - const stats = counts(ops); - - return { - title: change.title, - path: change.path, - before: change.before, - after: change.after, - exists: change.exists, - diff: renderUnified(ops), - additions: stats.additions, - deletions: stats.deletions, - }; -} - -/** Line number (1-based) where `needle` starts in the file, or null. */ -function lineOfMatch(path, needle) { - if (!needle) return null; - const contents = readFile(path); - if (contents === null) return null; - const index = contents.indexOf(needle); - if (index < 0) return null; - return contents.slice(0, index).split("\n").length; -} - -/** Open a scratch tab showing the proposed change. */ -async function showPreview(preview) { - if (!preview) return null; - - const header = [ - `--- a/${relativize(preview.path)}`, - `+++ b/${relativize(preview.path)}`, - ].join("\n"); - - const content = `${header}\n${preview.diff}\n`; - - try { - return await nova.workspace.openNewTextDocument({ - content: content, - syntax: "diff", - }); - } catch (err) { - warn("could not open the change preview:", err); - return null; - } -} - -module.exports = { - previewForTool, - proposedChange, - showPreview, - readFile, - lineOfMatch, - diffLines, - renderUnified, -}; diff --git a/Scripts/launch.js b/Scripts/launch.js new file mode 100644 index 0000000..9841603 --- /dev/null +++ b/Scripts/launch.js @@ -0,0 +1,147 @@ +// +// launch.js — building the command that starts or resumes a session. +// +// Nova exposes no way for an extension to open its built-in terminal or type +// into one (no terminal object in the API; `newTerminal:` in the app is an +// Objective-C menu selector with no JS bridge). So the extension prepares the +// command and puts it on the clipboard; you open Nova's terminal and paste. +// +// The upside of launching a plain CLI session is that it is a completely +// ordinary one: stored under ~/.claude/projects, resumable from any terminal, +// and eligible for Remote Control. +// + +const { conf, log, warn } = require("./util.js"); +const cli = require("./cli.js"); + +/** Quote a value for a POSIX shell. */ +function shellQuote(value) { + const text = String(value); + if (/^[A-Za-z0-9_./:@%+=-]+$/.test(text)) return text; + return "'" + text.replace(/'/g, `'\\''`) + "'"; +} + +/** + * Flags shared by new and resumed sessions, taken from settings so a launched + * session matches what the extension is configured for. + */ +function configuredFlags() { + const flags = []; + + const model = conf("claudenova.model", ""); + if (model) flags.push("--model", model); + + const effort = conf("claudenova.effort", ""); + if (effort) flags.push("--effort", effort); + + const mode = conf("claudenova.permissionMode", ""); + if (mode) flags.push("--permission-mode", mode); + + const extra = String(conf("claudenova.extraArgs", "")).trim(); + if (extra) { + for (const piece of extra.split(/\s+/)) flags.push(piece); + } + + return flags; +} + +/** + * Build a launch command line. + * + * @param options.resume session id to resume + * @param options.remoteControl enable Remote Control for the session + * @param options.cd prefix with a `cd` into the workspace + * @param options.binary executable path (defaults to the resolved one) + */ +function buildCommand(options = {}) { + const parts = []; + + if (options.cd !== false && nova.workspace.path) { + parts.push("cd", shellQuote(nova.workspace.path), "&&"); + } + + // `claude` on PATH reads better than an absolute path; fall back to the + // resolved binary when the shell would not find it. + parts.push(options.binary ? shellQuote(options.binary) : "claude"); + + if (options.resume) { + parts.push("--resume", shellQuote(options.resume)); + } + + if (options.remoteControl) { + parts.push("--remote-control"); + } + + for (const flag of configuredFlags()) { + parts.push(shellQuote(flag)); + } + + return parts.join(" "); +} + +/** + * Resolve which executable spelling to use: bare `claude` when the login shell + * can find it, otherwise the absolute path we resolved. + */ +async function preferredBinary() { + try { + const resolved = await cli.resolveBinary(); + const home = nova.environment["HOME"] || ""; + + // The standard install is on PATH for interactive shells; anything + // unusual (a configured path, a version manager shim) is safer spelled + // out in full. + if (resolved === nova.path.join(home, ".local/bin/claude")) return null; + if (resolved === "/opt/homebrew/bin/claude") return null; + if (resolved === "/usr/local/bin/claude") return null; + return resolved; + } catch (err) { + log("falling back to bare `claude`:", err.message || err); + return null; + } +} + +/** + * Put a launch command on the clipboard and tell the user it is ready. + * Resolves with the command. + */ +async function copyCommand(options = {}) { + const binary = await preferredBinary(); + const command = buildCommand(Object.assign({ binary: binary }, options)); + + try { + await nova.clipboard.writeText(command); + } catch (err) { + warn("could not write to the clipboard:", err); + nova.workspace.showErrorMessage( + "Could not copy the command. Run this in a terminal:\n\n" + command + ); + return command; + } + + const request = new NotificationRequest("claudenova-launch"); + request.title = options.resume ? "Resume command copied" : "Launch command copied"; + request.body = + command + "\n\nOpen a terminal and paste it."; + request.actions = ["OK", "Show Command"]; + + try { + const response = await nova.notifications.add(request); + if (response && response.actionIdx === 1) { + nova.workspace.showInformativeMessage(command); + } + } catch (err) { + // Notifications are a courtesy; the clipboard already has the command. + log("could not post the launch notification:", err); + } + + return command; +} + +module.exports = { + buildCommand, + copyCommand, + configuredFlags, + shellQuote, + preferredBinary, +}; diff --git a/Scripts/main.js b/Scripts/main.js index d1bfa81..14987be 100644 --- a/Scripts/main.js +++ b/Scripts/main.js @@ -1,23 +1,20 @@ // // main.js — extension entry point. // -// Owns the lifecycle: one Claude Code process per window, a session model fed -// from its output, and the views and commands that surface it. +// Chat happens in a terminal running `claude`. This extension is everything +// around that: the project's sessions, the commands that launch or resume one, +// and the account state behind them. // -const { conf, log, warn, relativize, oneLine } = require("./util.js"); +const { conf, log, warn, oneLine, relativize } = require("./util.js"); const cli = require("./cli.js"); const auth = require("./auth.js"); -const diff = require("./diff.js"); +const launch = require("./launch.js"); const store = require("./sessions-store.js"); -const { ClaudeClient } = require("./client.js"); -const { Session } = require("./session.js"); -const { Transcript } = require("./transcript.js"); const { Sidebar } = require("./sidebar.js"); -const { PermissionBroker } = require("./permissions.js"); const MODELS = [ - ["", "Default (from your Claude Code settings)"], + ["", "Claude Code default"], ["opus", "Opus"], ["sonnet", "Sonnet"], ["haiku", "Haiku"], @@ -25,6 +22,7 @@ const MODELS = [ ]; const PERMISSION_MODES = [ + ["", "Claude Code default"], ["manual", "Manual — ask before each tool"], ["acceptEdits", "Accept Edits — auto-approve file edits"], ["plan", "Plan — research only, no changes"], @@ -34,46 +32,43 @@ const PERMISSION_MODES = [ class Controller { constructor() { - this.session = new Session(); - this.transcript = new Transcript(this.session); - this.broker = new PermissionBroker(this.session, this.transcript); - this.client = null; + this.sessions = []; this.authState = null; this.sidebar = null; - this.starting = null; } // -- lifecycle --------------------------------------------------------- activate() { - this.transcript.showThinking = conf("claudenova.showThinking", true) !== false; - const controller = this; + this.sidebar = new Sidebar({ - session: this.session, + get sessions() { + return controller.sessions; + }, get authState() { return controller.authState; }, + get workspacePath() { + return nova.workspace.path; + }, describeAuth: () => auth.describe(this.authState), configuredModel: () => this.labelFor(MODELS, conf("claudenova.model", "")), - configuredPermissionMode: () => - this.labelFor(PERMISSION_MODES, conf("claudenova.permissionMode", "manual")), }); - this.session.onChange.on(() => { - this.sidebar.reload(); - this.transcript.update(); - }); + // Sessions live in ~/.claude, outside the workspace, so a file watcher + // would never fire for them. Rescanning when the section becomes + // visible keeps the list fresh at the moment it is looked at. + this.sidebar.onDidBecomeVisible(() => this.refreshSessions()); this.registerCommands(); this.watchConfig(); + this.refreshSessions(); this.refreshAuth().catch((err) => log("initial auth check failed:", err)); } dispose() { - if (this.client) this.client.stop(true); - this.transcript.dispose(); if (this.sidebar) this.sidebar.dispose(); } @@ -83,208 +78,30 @@ class Controller { } watchConfig() { - const reloadThinking = () => { - this.transcript.showThinking = conf("claudenova.showThinking", true) !== false; - this.transcript.update(); - }; - - nova.subscriptions.add(nova.config.onDidChange("claudenova.showThinking", reloadThinking)); - nova.subscriptions.add( - nova.workspace.config.onDidChange("claudenova.showThinking", reloadThinking) - ); + const reload = () => this.sidebar.reload(); + for (const key of ["claudenova.model", "claudenova.permissionMode"]) { + nova.subscriptions.add(nova.config.onDidChange(key, reload)); + nova.subscriptions.add(nova.workspace.config.onDidChange(key, reload)); + } nova.subscriptions.add( nova.config.onDidChange("claudenova.binaryPath", () => cli.forgetBinary()) ); } - // -- account ----------------------------------------------------------- + // -- data -------------------------------------------------------------- + + refreshSessions() { + this.sessions = store.recentSessions(nova.workspace.path); + if (this.sidebar) this.sidebar.reload(); + return this.sessions; + } async refreshAuth() { this.authState = await auth.status(); - this.sidebar.reload(); + if (this.sidebar) this.sidebar.reload(); return this.authState; } - // -- session ----------------------------------------------------------- - - sessionOptions(overrides = {}) { - const extra = String(conf("claudenova.extraArgs", "")).trim(); - - return Object.assign( - { - cwd: nova.workspace.path || undefined, - model: conf("claudenova.model", "") || undefined, - effort: conf("claudenova.effort", "") || undefined, - permissionMode: conf("claudenova.permissionMode", "manual"), - streamPartial: conf("claudenova.streamPartial", true) !== false, - extraArgs: extra ? extra.split(/\s+/) : [], - }, - overrides - ); - } - - /** Start a session if one is not already running. */ - async ensureSession() { - if (this.client && this.client.isRunning) return this.client; - if (this.starting) return this.starting; - - this.starting = this.startSession().finally(() => { - this.starting = null; - }); - return this.starting; - } - - async startSession(overrides = {}) { - if (this.client) this.client.stop(); - - this.clearStagedProposals(); - this.session.reset(); - this.session.setStatus("starting"); - - const client = new ClaudeClient({ - onMessage: (message) => this.session.handleMessage(message), - onPermission: (request, requestId) => this.broker.handle(request, requestId), - onPermissionCancelled: (requestId) => this.broker.cancel(requestId), - onExit: (status, stderr) => this.handleExit(status, stderr), - }); - - this.client = client; - - try { - await client.start(this.sessionOptions(overrides)); - } catch (err) { - this.client = null; - this.session.setStatus("error", String(err.message || err)); - throw err; - } - - this.session.sessionId = client.sessionId; - this.session.setStatus("idle"); - - if (conf("claudenova.openTranscript", true) !== false) { - await this.transcript.show(); - } - - return client; - } - - /** Remove proposal files left over from a previous session. */ - clearStagedProposals() { - const root = nova.path.join( - nova.workspace.path - ? nova.extension.workspaceStoragePath - : nova.extension.globalStoragePath, - "proposed" - ); - - try { - if (!nova.fs.access(root, nova.fs.F_OK)) return; - for (const name of nova.fs.listdir(root)) { - nova.fs.remove(nova.path.join(root, name)); - } - } catch (err) { - log("could not clear staged proposals:", err); - } - } - - handleExit(status, stderr) { - const wasBusy = this.session.isBusy; - this.session.setStatus("stopped"); - - for (const approval of this.session.approvals.slice()) { - approval.settled = true; - this.session.removeApproval(approval.requestId); - } - - if (status !== 0 && wasBusy) { - const detail = oneLine(stderr, 300) || `Claude Code exited with status ${status}.`; - this.session.addNotice(detail, "error"); - nova.workspace.showErrorMessage("Claude Code stopped unexpectedly.\n\n" + detail); - } - - this.transcript.flush().catch(() => { - /* the tab may be closed */ - }); - } - - // -- asking ------------------------------------------------------------ - - async ask(text, context) { - if (!text || !text.trim()) return; - - try { - await this.ensureSession(); - } catch (err) { - nova.workspace.showErrorMessage(String(err.message || err)); - return; - } - - this.session.addUserTurn(text, context ? context.label : null); - await this.transcript.show(); - - try { - this.client.send(context ? context.content(text) : text); - } catch (err) { - this.session.setStatus("error", String(err.message || err)); - nova.workspace.showErrorMessage("Could not send your message: " + (err.message || err)); - } - } - - promptAndAsk(prefill, context) { - nova.workspace.showInputPalette( - context ? `Ask Claude about ${context.label}` : "Ask Claude", - { placeholder: "What would you like Claude to do?", value: prefill || "" }, - (value) => { - if (value === null) return; - this.ask(value, context); - } - ); - } - - /** Build the context payload for the current editor selection. */ - selectionContext(editor) { - if (!editor) return null; - if (conf("claudenova.includeSelection", true) === false) return null; - - const range = editor.selectedRange; - if (!range || range.empty) return null; - - const document = editor.document; - const path = document.path; - if (!path) return null; - - const before = document.getTextInRange(new Range(0, range.start)); - const startLine = before.split("\n").length; - const selected = document.getTextInRange(range); - const endLine = startLine + selected.split("\n").length - 1; - - const label = `${relativize(path)}:${startLine}-${endLine}`; - const syntax = document.syntax || ""; - - return { - label: label, - content: (question) => - [ - question, - "", - `From \`${relativize(path)}\` lines ${startLine}–${endLine}:`, - "```" + syntax, - selected, - "```", - ].join("\n"), - }; - } - - fileContext(editor) { - const path = editor && editor.document && editor.document.path; - if (!path) return null; - - return { - label: relativize(path), - content: (question) => `${question}\n\nThe file in question is \`${path}\`.`, - }; - } - // -- commands ---------------------------------------------------------- registerCommands() { @@ -292,48 +109,12 @@ class Controller { nova.subscriptions.add(nova.commands.register(name, callback)); }; - register("claudenova.ask", (editor) => { - const context = TextEditor.isTextEditor(editor) - ? this.selectionContext(editor) - : this.selectionContext(nova.workspace.activeTextEditor); - this.promptAndAsk("", context); - }); - - register("claudenova.askAboutSelection", (editor) => { - const context = this.selectionContext(editor); - if (!context) { - nova.workspace.showWarningMessage("Select some code first, then ask Claude about it."); - return; - } - this.promptAndAsk("", context); - }); - - register("claudenova.addFiles", () => this.commandAddFiles()); - - register("claudenova.addFileContext", (editor) => { - const context = this.fileContext(editor); - if (!context) { - nova.workspace.showWarningMessage("Save the file first so Claude can read it."); - return; - } - this.promptAndAsk("", context); - }); - - register("claudenova.plan", (editor) => { - const context = TextEditor.isTextEditor(editor) - ? this.selectionContext(editor) - : this.selectionContext(nova.workspace.activeTextEditor); - this.commandPlan(context); - }); - - register("claudenova.showTranscript", () => { - this.transcript.show().catch((err) => warn("could not open the transcript:", err)); - }); - register("claudenova.newSession", () => this.commandNewSession()); register("claudenova.resumeSession", () => this.commandResumeSession()); - register("claudenova.interrupt", () => this.commandInterrupt()); - register("claudenova.endSession", () => this.commandEndSession()); + register("claudenova.copyResume", () => this.sessionCommand("resume")); + register("claudenova.sessionAction", () => this.commandSessionAction()); + register("claudenova.refreshSessions", () => this.refreshSessions()); + register("claudenova.copyFileReference", (editor) => this.commandCopyFileReference(editor)); register("claudenova.setModel", () => this.commandSetModel()); register("claudenova.setPermissionMode", () => this.commandSetPermissionMode()); @@ -344,200 +125,126 @@ class Controller { register("claudenova.refreshStatus", () => this.refreshAuth()); register("claudenova.setApiKey", () => this.commandSetApiKey()); register("claudenova.clearApiKey", () => this.commandClearApiKey()); - - register("claudenova.approvalAction", () => this.commandApprovalAction()); - register("claudenova.reviewApproval", () => this.approvalCommand("review")); - register("claudenova.allowApproval", () => this.approvalCommand("allow")); - register("claudenova.allowApprovalAlways", () => this.approvalCommand("always")); - register("claudenova.denyApproval", () => this.approvalCommand("deny")); - - register("claudenova.openActivityFile", () => this.commandOpenActivityFile()); - register("claudenova.copyActivity", () => this.commandCopyActivity()); - - register("claudenova.openChangedFile", () => this.commandOpenChangedFile()); - register("claudenova.showChangeDiff", () => this.commandShowChangeDiff()); - register("claudenova.clearChanges", () => this.session.clearChangedFiles()); - } - - /** - * Ask in plan mode: Claude researches and proposes, changing nothing until - * you approve the plan. Switches a running session over rather than - * starting a new one, so the existing context carries into the planning. - */ - async commandPlan(context) { - try { - await this.ensureSession(); - } catch (err) { - nova.workspace.showErrorMessage(String(err.message || err)); - return; - } - - if (this.session.permissionMode !== "plan") { - try { - await this.client.setPermissionMode("plan"); - this.session.permissionMode = "plan"; - this.session.changed(); - } catch (err) { - warn("could not switch to plan mode:", err); - nova.workspace.showWarningMessage( - "Could not switch to plan mode: " + (err.message || err) - ); - return; - } - } - - nova.workspace.showInputPalette( - context ? `Plan with Claude about ${context.label}` : "Plan with Claude", - { placeholder: "What should Claude plan?" }, - (value) => { - if (value === null) return; - this.ask(value, context); - } - ); - } - - /** - * Pick files (or a directory) to put in front of Claude, with the current - * selection's line range attached when there is one. The editor-menu - * command covers the active file; this covers everything else. - */ - commandAddFiles() { - nova.workspace.showFileChooser( - "Choose files for Claude to work with", - { - prompt: "Add", - allowFiles: true, - allowDirectories: true, - allowMultiple: true, - }, - (paths) => { - if (!paths || paths.length === 0) return; - - const listed = paths.map((path) => "`" + relativize(path) + "`").join(", "); - const label = paths.length === 1 ? relativize(paths[0]) : `${paths.length} files`; - - this.promptAndAsk("", { - label: label, - content: (question) => - `${question}\n\nWork with ${paths.length === 1 ? "this file" : "these files"}: ${listed}`, - }); - } - ); } async commandNewSession() { try { - await this.startSession(); - this.session.addNotice("New session started.", "info"); + await launch.copyCommand({}); } catch (err) { nova.workspace.showErrorMessage(String(err.message || err)); } } + /** Pick a session from a palette, then copy its resume command. */ commandResumeSession() { - const sessions = store.recentSessions(nova.workspace.path); + const sessions = this.refreshSessions(); + if (sessions.length === 0) { - nova.workspace.showInformativeMessage("No previous Claude sessions were found for this project."); + nova.workspace.showInformativeMessage( + "No previous Claude sessions were found for this project." + ); return; } - const choices = sessions.map((session) => { - const when = session.modifiedAt.toLocaleString(); - return `${session.label} — ${when}`; - }); + const choices = sessions.map( + (session) => `${oneLine(session.label, 60)} — ${session.relative}` + ); nova.workspace.showChoicePalette( choices, { placeholder: "Resume a Claude session" }, (choice, index) => { if (index === null || index === undefined) return; - const picked = sessions[index]; - this.startSession({ resume: picked.id }) - .then(() => { - this.session.addNotice(`Resumed session ${picked.id.slice(0, 8)}.`, "info"); - }) - .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); + this.copyFor(sessions[index], "resume"); } ); } - async commandInterrupt() { - if (!this.client || !this.client.isRunning) { - nova.workspace.showInformativeMessage("No Claude session is running."); + /** An action row under a session was activated. */ + commandSessionAction() { + const selection = this.sidebar.sessionsView.selection; + const row = selection && selection[0]; + if (!row || !row.session) return; + this.copyFor(row.session, row.id); + } + + /** A session row (not an action row) was activated. */ + sessionCommand(action) { + const selection = this.sidebar.sessionsView.selection; + const selected = selection && selection[0]; + const session = selected && (selected.session || (selected.path ? selected : null)); + if (!session) return; + this.copyFor(session, action); + } + + async copyFor(session, action) { + if (action === "reveal") { + try { + nova.fs.reveal(session.path); + } catch (err) { + nova.workspace.showErrorMessage("Could not reveal the session file: " + err); + } return; } try { - await this.client.interrupt(); - this.session.addNotice("Interrupted.", "info"); - this.session.setStatus("idle"); + await launch.copyCommand({ + resume: session.id, + remoteControl: action === "resume-remote", + }); } catch (err) { - warn("interrupt failed:", err); - nova.workspace.showWarningMessage("Could not interrupt Claude: " + (err.message || err)); + nova.workspace.showErrorMessage(String(err.message || err)); } } - commandEndSession() { - if (!this.client) { - nova.workspace.showInformativeMessage("No Claude session is running."); + /** + * Copy an @-mention for the active file, for pasting into a terminal + * session. Claude Code resolves `@path` against the project directory. + */ + commandCopyFileReference(editor) { + const target = TextEditor.isTextEditor(editor) ? editor : nova.workspace.activeTextEditor; + const path = target && target.document && target.document.path; + + if (!path) { + nova.workspace.showWarningMessage("Save the file first so Claude can reference it."); return; } - this.client.stop(); - this.client = null; - this.session.setStatus("stopped"); - this.transcript.flush().catch(() => { - /* the tab may be closed */ - }); + + const reference = "@" + relativize(path); + nova.clipboard.writeText(reference); + nova.workspace.showInformativeMessage( + `Copied ${reference} — paste it into your Claude session.` + ); } commandSetModel() { const labels = MODELS.map((entry) => entry[1]); nova.workspace.showChoicePalette(labels, { placeholder: "Model" }, (choice, index) => { if (index === null || index === undefined) return; - - const value = MODELS[index][0]; - nova.workspace.config.set("claudenova.model", value); - - if (this.client && this.client.isRunning) { - this.client - .setModel(value || undefined) - .then(() => { - this.session.model = value || null; - this.session.changed(); - }) - .catch((err) => warn("could not change the model:", err)); - } + nova.workspace.config.set("claudenova.model", MODELS[index][0]); + this.sidebar.reload(); }); } commandSetPermissionMode() { const labels = PERMISSION_MODES.map((entry) => entry[1]); - nova.workspace.showChoicePalette(labels, { placeholder: "Permission mode" }, (choice, index) => { - if (index === null || index === undefined) return; - - const value = PERMISSION_MODES[index][0]; - nova.workspace.config.set("claudenova.permissionMode", value); - - if (this.client && this.client.isRunning) { - this.client - .setPermissionMode(value) - .then(() => { - this.session.permissionMode = value; - this.session.changed(); - }) - .catch((err) => warn("could not change the permission mode:", err)); + nova.workspace.showChoicePalette( + labels, + { placeholder: "Permission mode" }, + (choice, index) => { + if (index === null || index === undefined) return; + nova.workspace.config.set("claudenova.permissionMode", PERMISSION_MODES[index][0]); + this.sidebar.reload(); } - }); + ); } - // -- account commands -------------------------------------------------- + // -- account ----------------------------------------------------------- commandSignIn() { nova.workspace.showActionPanel( "How would you like to sign in to Claude?", - { - buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"], - }, + { buttons: ["Claude Subscription", "Anthropic Console", "Open a Terminal", "Cancel"] }, (index) => { if (index === null || index === 3) return; @@ -545,7 +252,7 @@ class Controller { auth.signInViaTerminal() .then(() => { nova.workspace.showInformativeMessage( - "Finish signing in in the terminal window, then use Refresh in the Claude sidebar." + "Finish signing in in the terminal window, then refresh the Claude sidebar." ); }) .catch((err) => nova.workspace.showErrorMessage(String(err.message || err))); @@ -556,7 +263,9 @@ class Controller { .then(async (ok) => { if (!ok) return; await this.refreshAuth(); - nova.workspace.showInformativeMessage("Signed in to Claude as " + auth.describe(this.authState)); + nova.workspace.showInformativeMessage( + "Signed in to Claude as " + auth.describe(this.authState) + ); }) .catch((err) => { nova.workspace.showErrorMessage( @@ -595,7 +304,7 @@ class Controller { if (cli.storedApiKey()) { lines.push(""); - lines.push("An API key stored in your keychain is being passed to Claude Code."); + lines.push("An API key stored in your keychain is passed to Claude Code."); } const version = await cli.version().catch(() => null); @@ -619,7 +328,7 @@ class Controller { try { cli.setStoredApiKey(key); nova.workspace.showInformativeMessage( - "Saved. Start a new Claude session for it to take effect." + "Saved. It applies to sessions you start from now on." ); this.refreshAuth(); } catch (err) { @@ -631,100 +340,9 @@ class Controller { commandClearApiKey() { cli.clearStoredApiKey(); - nova.workspace.showInformativeMessage( - "Removed the stored API key. Start a new Claude session for it to take effect." - ); + nova.workspace.showInformativeMessage("Removed the stored API key."); this.refreshAuth(); } - - // -- approval commands ------------------------------------------------- - - /** An action row under a pending request was activated. */ - commandApprovalAction() { - const selection = this.sidebar.approvalsView.selection; - const row = selection && selection[0]; - if (!row || !row.approval) return; - this.broker.decide(row.approval, row.id); - } - - approvalCommand(choice) { - const selection = this.sidebar.approvalsView.selection; - const selected = selection && selection[0]; - - // Selecting an action row and using the context menu should still act - // on its parent request. - const approval = - (selected && (selected.approval || (selected.request ? selected : null))) || - this.session.approvals[0]; - - if (!approval) { - nova.workspace.showInformativeMessage("Nothing is waiting for approval."); - return; - } - - this.broker.decide(approval, choice); - } - - // -- activity and changes ---------------------------------------------- - - commandOpenActivityFile() { - const selection = this.sidebar.activityView.selection; - const entry = selection && selection[0]; - if (!entry) return; - - if (entry.filePath) { - nova.workspace.openFile(entry.filePath); - } else { - this.commandCopyActivity(); - } - } - - commandCopyActivity() { - const selection = this.sidebar.activityView.selection; - const entry = selection && selection[0]; - if (!entry) return; - - const details = [ - `${entry.name} — ${entry.state}`, - "", - JSON.stringify(entry.input, null, 2), - ]; - if (entry.result) { - details.push("", "Result:", entry.result); - } - - nova.clipboard.writeText(details.join("\n")); - nova.workspace.showInformativeMessage("Copied the tool call to the clipboard."); - } - - commandOpenChangedFile() { - const selection = this.sidebar.changesView.selection; - for (const change of selection || []) { - nova.workspace.openFile(change.path); - } - } - - commandShowChangeDiff() { - const selection = this.sidebar.changesView.selection; - const change = selection && selection[0]; - if (!change) return; - - const tool = this.session.entries - .filter((entry) => entry.kind === "tool" && entry.filePath === change.path) - .pop(); - - if (!tool) { - nova.workspace.openFile(change.path); - return; - } - - const preview = diff.previewForTool(tool.name, tool.input); - if (preview) { - diff.showPreview(preview); - } else { - nova.workspace.openFile(change.path); - } - } } const controller = new Controller(); diff --git a/Scripts/permissions.js b/Scripts/permissions.js deleted file mode 100644 index 5601f56..0000000 --- a/Scripts/permissions.js +++ /dev/null @@ -1,938 +0,0 @@ -// -// permissions.js — answering the CLI's `can_use_tool` requests. -// -// Every prompt is registered in the sidebar first and only then surfaced as a -// panel. Dismissing the panel therefore does not strand the session: the -// request stays in "Needs Approval" until it is answered from the context -// menu, or the CLI withdraws it. -// -// Two tools are their own interaction surface rather than a yes/no gate, and -// get purpose-built handling: -// -// AskUserQuestion — the host collects the answers and returns them by -// rewriting the tool input (`updatedInput.answers`). -// ExitPlanMode — the host shows the plan and either approves it (moving -// the session out of plan mode) or sends back feedback. -// - -const { log, oneLine, stripAnsi, warn, conf, basename } = require("./util.js"); -const { summarizeTool, friendlyToolName, planTitle } = require("./session.js"); -const diff = require("./diff.js"); - -const ALLOW = "Allow"; -const ALLOW_ALWAYS = "Allow for Session"; -const DENY = "Deny"; -const REVIEW = "Review Change…"; -const OTHER = "Other…"; - -const FEEDBACK_HEADER = - "\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 deleted file mode 100644 index 4af7bd5..0000000 --- a/docs/features/questions.md +++ /dev/null @@ -1,47 +0,0 @@ -# Questions - -**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_askQuestions`, -`_askOne`, `_askMultiple`) - -When Claude needs a decision it calls `AskUserQuestion`, which arrives as a -`can_use_tool` request with `requires_user_interaction: true`. - -## Input - -```jsonc -{"questions":[{ - "question":"Do you prefer tabs or spaces?", - "header":"Indentation", - "multiSelect":false, - "options":[{"label":"Tabs","description":"…"},{"label":"Spaces","description":"…"}] -}]} -``` - -## Answering - -The host **rewrites the tool input**; there is no separate answer channel: - -```jsonc -{"behavior":"allow", - "updatedInput":{…input, "answers":{"":""}}} -``` - -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 deleted file mode 100644 index f77b93e..0000000 --- a/docs/features/session-history.md +++ /dev/null @@ -1,39 +0,0 @@ -# Session history - -**Status:** Implemented · **Code:** `Scripts/sessions-store.js` - -`Resume Session…` lists this project's previous Claude Code sessions — including -ones started in a terminal — without shelling out to the interactive picker. - -## Where Claude Code stores sessions - -``` -~/.claude/projects//.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 deleted file mode 100644 index ebeb329..0000000 --- a/docs/features/session-transport.md +++ /dev/null @@ -1,94 +0,0 @@ -# Session transport - -**Status:** Implemented · **Code:** `Scripts/client.js`, `Scripts/cli.js` - -Drives the `claude` executable in streaming JSON mode and owns the wire -protocol. Everything above this layer deals in parsed messages. - -## Command line - -``` -claude --print - --input-format stream-json --output-format stream-json --verbose - --permission-prompt-tool stdio - [--include-partial-messages] - (--session-id | --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/sessions.md b/docs/features/sessions.md new file mode 100644 index 0000000..ec791c4 --- /dev/null +++ b/docs/features/sessions.md @@ -0,0 +1,56 @@ +# Sessions sidebar + +**Status:** Implemented · **Code:** `Scripts/sidebar.js`, `Scripts/sessions-store.js` + +Two sections: **Claude** (account, model, permission mode) and **Sessions** (the +project's conversations, newest first, each expanding into its actions). + +``` +Claude + Account you@example.com · Pro + Model Opus + Permissions Plan only +Sessions + ▾ fix the parser bug 2h ago + Copy Resume Command + Copy Resume + Remote Control + Reveal Session File +``` + +## Where sessions come from + +Claude Code stores one JSONL transcript per session: + +``` +~/.claude/projects//.jsonl +``` + +The slug is the project path with `/` and `.` flattened to `-` +(`/Users/me/git.example.ca/thing` → `-Users-me-git-example-ca-thing`). Files are +sorted by mtime; `firstPrompt()` reads up to 40 lines looking for the first +non-meta, non-synthetic user message to use as the row's label, falling back to +the short session id. + +**Sessions started in a terminal appear here too** — it is the CLI's own store, +not something this extension maintains. + +## Refreshing + +Sessions live in `~/.claude`, **outside the workspace**, so `nova.fs.watch` +would never fire for them (and would cost a callback on every project file +change). Instead the list is rescanned when the Sessions section becomes +visible, plus on demand from the header's refresh button. + +## Row plumbing + +- `TreeItem.command` fires on **double-click**. A session row copies its resume + command; an action row runs its own action via `claudenova.sessionAction`, + which reads `sessionsView.selection[0]`. +- Session rows are `Collapsed` by default — with 25 rows, expanding all of them + would bury the list. +- `getParent()` returns the session for action rows so `reveal()` works. + +## Gotchas + +- `firstPrompt()` uses `readline()` and stops early: transcripts reach megabytes. +- Zero-byte and non-`.jsonl` entries are skipped. diff --git a/docs/features/sidebar.md b/docs/features/sidebar.md deleted file mode 100644 index 405f77a..0000000 --- a/docs/features/sidebar.md +++ /dev/null @@ -1,42 +0,0 @@ -# Sidebar - -**Status:** Implemented · **Code:** `Scripts/sidebar.js` - -Four TreeView sections under one sidebar (`claude`). Sections are declared in -`extension.json`; each is backed by a `TreeDataProvider` whose identifier matches -the section id. - -| Section | Rows | -| --- | --- | -| `claude.session` | Status, Account, Model, Permissions, Usage | -| `claude.approvals` | Pending requests, each **expanded** with its choices as child rows | -| `claude.activity` | Tool calls, newest first, capped at 120 | -| `claude.changes` | Files Claude actually modified | - -## Approvals are hierarchical - -The parent row is the request; children are the actions from -`actionsFor(approval)` in `permissions.js`. Selecting a child and activating it -runs `claudenova.approvalAction`, which reads `approvalsView.selection[0]` and -calls `broker.decide(row.approval, row.id)`. - -This is what makes a decision possible without any panel being open — dismissing -a notification never strands the session. - -Action rows are generated, so they follow state: a plan shows -`Write Feedback…` until a feedback tab exists, then `Send Feedback`. - -## Row plumbing - -- `TreeItem.command` fires on **double-click**, not single click. Context menus - in `extension.json` mirror the same commands, gated on `contextValue`. -- `TreeItem.path` gives a file row its filetype icon and reveal behaviour. -- `getParent()` must return the parent for `reveal()` to work; action rows - return their approval. -- Reloads are throttled to 200ms — every streamed token changes the session. - -## Labels - -`friendlyToolName()` renames the interaction-surface tools: `ExitPlanMode` → -`Plan`, `AskUserQuestion` → `Question`. `modeLabel()` maps permission modes; -note the CLI reports `default` for the mode its own flag calls `manual`. diff --git a/docs/features/tool-approvals.md b/docs/features/tool-approvals.md deleted file mode 100644 index a6d4c27..0000000 --- a/docs/features/tool-approvals.md +++ /dev/null @@ -1,89 +0,0 @@ -# Tool approvals - -**Status:** Implemented · **Code:** `Scripts/permissions.js`, `Scripts/diff.js` - -Every `can_use_tool` control request becomes a decision surface. The request is -registered in the sidebar **first**, then surfaced — so dismissing a -notification or panel never strands the session. - -## Routing - -`_present(approval)` picks the surface: - -| Request | Surface | -| --- | --- | -| `AskUserQuestion` | [Questions](questions.md) | -| `ExitPlanMode` | [Plan mode](plan-mode.md) | -| A file change (`preview` reconstructed) | Editable proposal, below | -| Other `requires_user_interaction` | Denied with an explanation | -| Anything else | Action panel (Allow / Allow for Session / Review / Deny) | - -## `PermissionResult` shapes - -```jsonc -{"behavior":"allow"} -{"behavior":"allow","updatedInput":{…}} // run with different input -{"behavior":"allow","updatedPermissions":[{…}]} // + change rules or mode -{"behavior":"deny","message":"…"} // message is shown to Claude -``` - -`updatedPermissions` entries used here: `{type:"addRules", rules:[{toolName}], -behavior:"allow", destination:"session"}` for *Allow for Session*, and -`{type:"setMode", mode, destination:"session"}` for plan approval. - -## Editable proposals - -The claudecode.nvim lesson: a proposal should be a **document you can amend**, -not a yes/no gate. When Claude wants to edit or create a file: - -1. `diff.proposedChange()` reconstructs the file **as it would be after** the - change (applying `old_string`→`new_string`, or the `edits[]` in order). -2. That content is staged to - `/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 deleted file mode 100644 index f4bd191..0000000 --- a/docs/features/transcript.md +++ /dev/null @@ -1,45 +0,0 @@ -# Transcript - -**Status:** Implemented · **Code:** `Scripts/transcript.js` - -Nova sidebars hold only tree rows, so the readable conversation lives in an -editor tab as Markdown. - -## Where it lives - -`/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 0e58edf..a4f704c 100644 --- a/extension.json +++ b/extension.json @@ -2,7 +2,7 @@ "identifier": "unsupervised.claudenova", "name": "Claude", "organization": "Unsupervised", - "description": "Claude Code in Nova — a chat sidebar, a live transcript, and tool approvals you review in the editor.", + "description": "Launch and resume Claude Code sessions for your project, and keep track of them from Nova's sidebar.", "version": "1.0", "min_runtime": "10", "categories": ["sidebars", "commands"], @@ -21,15 +21,9 @@ "commands": { "extensions": [ - {"title": "Ask Claude…", "command": "claudenova.ask", "shortcut": "cmd-shift-l"}, - {"title": "Plan with Claude…", "command": "claudenova.plan", "shortcut": "cmd-shift-p"}, - {"title": "Add Files to Context…", "command": "claudenova.addFiles"}, - {"title": "Show Transcript", "command": "claudenova.showTranscript"}, - {"separator": true}, - {"title": "New Session", "command": "claudenova.newSession"}, + {"title": "New Session…", "command": "claudenova.newSession", "shortcut": "cmd-shift-l"}, {"title": "Resume Session…", "command": "claudenova.resumeSession"}, - {"title": "Stop Claude", "command": "claudenova.interrupt"}, - {"title": "End Session", "command": "claudenova.endSession"}, + {"title": "Refresh Sessions", "command": "claudenova.refreshSessions"}, {"separator": true}, {"title": "Set Model…", "command": "claudenova.setModel"}, {"title": "Set Permission Mode…", "command": "claudenova.setPermissionMode"}, @@ -41,8 +35,7 @@ {"title": "Clear Stored API Key", "command": "claudenova.clearApiKey"} ], "editor": [ - {"title": "Ask Claude About Selection…", "command": "claudenova.askAboutSelection", "shortcut": "cmd-ctrl-l"}, - {"title": "Add File to Claude's Context", "command": "claudenova.addFileContext"} + {"title": "Copy File Reference for Claude", "command": "claudenova.copyFileReference"} ] }, @@ -57,10 +50,10 @@ { "key": "claudenova.model", "title": "Model", - "description": "Model alias passed to Claude Code. “Default” uses whatever your Claude Code settings specify.", + "description": "Passed as --model to sessions this extension launches.", "type": "enum", "values": [ - ["", "Default"], + ["", "Claude Code default"], ["opus", "Opus"], ["sonnet", "Sonnet"], ["haiku", "Haiku"], @@ -71,10 +64,10 @@ { "key": "claudenova.effort", "title": "Effort", - "description": "How much thinking and exploration Claude does per turn.", + "description": "Passed as --effort to sessions this extension launches.", "type": "enum", "values": [ - ["", "Default"], + ["", "Claude Code default"], ["low", "Low"], ["medium", "Medium"], ["high", "High"], @@ -86,59 +79,22 @@ { "key": "claudenova.permissionMode", "title": "Permission mode", - "description": "How tool use is approved. “Manual” asks you before every tool that needs permission.", + "description": "Passed as --permission-mode to sessions this extension launches.", "type": "enum", "values": [ + ["", "Claude Code default"], ["manual", "Manual — ask before each tool"], ["acceptEdits", "Accept Edits — auto-approve file edits"], ["plan", "Plan — research only, no changes"], ["auto", "Auto — Claude decides what needs asking"], ["bypassPermissions", "Bypass — approve everything (dangerous)"] ], - "default": "manual" - }, - { - "key": "claudenova.reviewEdits", - "title": "Reviewing file changes", - "description": "How Claude's edits are presented when they need approval.", - "type": "enum", - "values": [ - ["editor", "Open the proposed file — save to apply, close to reject"], - ["panel", "Ask in a panel, with the diff on request"] - ], - "default": "editor" - }, - { - "key": "claudenova.openTranscript", - "title": "Open the transcript automatically", - "description": "Open the transcript tab when a session starts.", - "type": "boolean", - "default": true - }, - { - "key": "claudenova.streamPartial", - "title": "Stream text as it is generated", - "description": "Show Claude's reply token by token instead of message by message.", - "type": "boolean", - "default": true - }, - { - "key": "claudenova.showThinking", - "title": "Show thinking in the transcript", - "type": "boolean", - "default": true - }, - { - "key": "claudenova.includeSelection", - "title": "Include the editor selection with prompts", - "description": "When text is selected, send the file, line range, and selected text along with your question.", - "type": "boolean", - "default": true + "default": "" }, { "key": "claudenova.extraArgs", "title": "Additional CLI arguments", - "description": "Space-separated arguments appended to the `claude` command line. For advanced use.", + "description": "Appended to the launch command. For advanced use.", "type": "string", "placeholder": "--add-dir ../shared" } @@ -182,61 +138,38 @@ "largeImage": "claude-large", "sections": [ { - "id": "claude.session", - "name": "Session", + "id": "claude.status", + "name": "Claude", "allowMultiple": false, - "placeholderText": "No session yet.\nPress New Session to start one.", + "placeholderText": "Checking Claude Code…", "placeholderImage": "claude-placeholder", "headerCommands": [ - {"title": "Ask Claude", "image": "__builtin.action", "tooltip": "Ask Claude…", "command": "claudenova.ask"}, - {"title": "New Session", "image": "__builtin.add", "tooltip": "Start a new session", "command": "claudenova.newSession"}, + {"title": "New Session", "image": "__builtin.add", "tooltip": "Copy a command to start a session", "command": "claudenova.newSession"}, {"title": "Refresh", "image": "__builtin.refresh", "tooltip": "Refresh account status", "command": "claudenova.refreshStatus"} ], "contextCommands": [ {"title": "Set Model…", "command": "claudenova.setModel"}, {"title": "Set Permission Mode…", "command": "claudenova.setPermissionMode"}, {"separator": true}, - {"title": "Stop Claude", "command": "claudenova.interrupt"}, - {"title": "End Session", "command": "claudenova.endSession"} + {"title": "Sign In…", "command": "claudenova.signIn"}, + {"title": "Show Account Status", "command": "claudenova.authStatus"} ] }, { - "id": "claude.approvals", - "name": "Needs Approval", + "id": "claude.sessions", + "name": "Sessions", "allowMultiple": false, - "placeholderText": "Nothing waiting on you.", + "placeholderText": "No sessions yet.\nStart one to see it here.", "placeholderImage": "claude-placeholder", - "contextCommands": [ - {"title": "Choose", "command": "claudenova.approvalAction", "when": "viewItem == 'approval-action'"}, - {"title": "Review…", "command": "claudenova.reviewApproval", "when": "viewItem == 'approval'"}, - {"title": "Allow", "command": "claudenova.allowApproval", "when": "viewItem == 'approval'"}, - {"title": "Allow for This Session", "command": "claudenova.allowApprovalAlways", "when": "viewItem == 'approval'"}, - {"title": "Deny", "command": "claudenova.denyApproval", "when": "viewItem == 'approval'"} - ] - }, - { - "id": "claude.activity", - "name": "Activity", - "allowMultiple": false, - "placeholderText": "Claude's tool use shows up here.", "headerCommands": [ - {"title": "Show Transcript", "image": "__builtin.path", "tooltip": "Show the transcript", "command": "claudenova.showTranscript"} + {"title": "New Session", "image": "__builtin.add", "tooltip": "Copy a command to start a session", "command": "claudenova.newSession"}, + {"title": "Refresh", "image": "__builtin.refresh", "tooltip": "Rescan this project's sessions", "command": "claudenova.refreshSessions"} ], "contextCommands": [ - {"title": "Open File", "command": "claudenova.openActivityFile", "when": "viewItem == 'activity-file'"}, - {"title": "Copy Details", "command": "claudenova.copyActivity"} - ] - }, - { - "id": "claude.changes", - "name": "Changed Files", - "allowMultiple": true, - "placeholderText": "Files Claude edits appear here.", - "contextCommands": [ - {"title": "Open File", "command": "claudenova.openChangedFile"}, - {"title": "Show Change…", "command": "claudenova.showChangeDiff"}, + {"title": "Choose", "command": "claudenova.sessionAction", "when": "viewItem == 'session-action'"}, + {"title": "Copy Resume Command", "command": "claudenova.copyResume", "when": "viewItem == 'session'"}, {"separator": true}, - {"title": "Clear List", "command": "claudenova.clearChanges"} + {"title": "Refresh Sessions", "command": "claudenova.refreshSessions"} ] } ]