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