Move to a terminal driven integration to have better interactions

This commit is contained in:
2026-08-11 17:10:26 -03:00
parent 89d1a06204
commit b05c28cff0
26 changed files with 679 additions and 4058 deletions
+31 -20
View File
@@ -1,29 +1,40 @@
# Feature documentation
One file per capability. Each records what the feature does, how it is wired,
the protocol facts it depends on, and the traps found while building it — so a
future session does not have to re-derive them.
the protocol facts it depends on, and the traps found while building it.
| Feature | Status | Code |
| --- | --- | --- |
| [Session transport](session-transport.md) | Implemented | `Scripts/client.js` |
| [Conversation model](conversation-model.md) | Implemented | `Scripts/session.js` |
| [Transcript](transcript.md) | Implemented | `Scripts/transcript.js` |
| [Sidebar](sidebar.md) | Implemented | `Scripts/sidebar.js` |
| [Tool approvals](tool-approvals.md) | Implemented | `Scripts/permissions.js`, `Scripts/diff.js` |
| [Plan mode](plan-mode.md) | Implemented | `Scripts/permissions.js` |
| [Questions](questions.md) | Implemented | `Scripts/permissions.js` |
| [Launching sessions](launching.md) | Implemented | `Scripts/launch.js` |
| [Sessions sidebar](sessions.md) | Implemented | `Scripts/sidebar.js`, `Scripts/sessions-store.js` |
| [Authentication](authentication.md) | Implemented | `Scripts/auth.js`, `Scripts/cli.js` |
| [Context and prompting](context.md) | Implemented | `Scripts/main.js` |
| [Session history](session-history.md) | Implemented | `Scripts/sessions-store.js` |
| [IDE bridge](ide-bridge.md) | **Proposed — not built** | — |
| [IDE bridge](ide-bridge.md) | **Next — not built** | — |
## The one-paragraph architecture
## Architecture
The extension spawns one `claude` process per window and speaks its
newline-delimited JSON protocol over stdio. Session messages (assistant text,
tool calls, results) and control traffic (the initialize handshake, permission
prompts, interrupts) share that pipe. `client.js` owns the framing; `session.js`
turns messages into a conversation model; `transcript.js`, `sidebar.js` and
`permissions.js` are views over that model. Nothing else runs — no Node sidecar,
no bundled copy of Claude Code, no network access of the extension's own.
Chat happens in a terminal running `claude`. The extension does not own a
session and does not proxy the conversation; it prepares launch commands, tracks
the project's sessions, and manages the account behind them.
```
Nova sidebar ──copy command──> clipboard ──you paste──> terminal running claude
│ │
└── reads ~/.claude/projects/<slug>/*.jsonl ───────────────────┘
(the same store the CLI uses)
```
Because a launched session is an ordinary CLI session, it is resumable from any
terminal and eligible for `--remote-control` — nothing about it is specific to
this extension.
## History
The extension previously drove `claude --print` over its stream-json protocol
and rendered the conversation into a Markdown tab, with approvals, plan review
and question answering in Nova panels. That was removed: modal panels truncated
long questions and lost their input whenever you clicked away to check
something. The terminal TUI handles those interactions properly.
That implementation — including the stream-json transport, the editable-proposal
flow, and the change reconstruction in `Scripts/diff.js` — is preserved in commit
`89d1a06` and is the starting point for the [IDE bridge](ide-bridge.md).
-40
View File
@@ -1,40 +0,0 @@
# Context and prompting
**Status:** Implemented · **Code:** `Scripts/main.js`
## Asking
| Command | Shortcut | Context attached |
| --- | --- | --- |
| `claudenova.ask` | ⌘⇧L | The active editor's selection, if any |
| `claudenova.askAboutSelection` | ⌃⌘L | Required — warns when nothing is selected |
| `claudenova.addFileContext` | — | The active file's path |
| `claudenova.addFiles` | — | Files/folders chosen from `showFileChooser` |
| `claudenova.plan` | ⌘⇧P | As `ask`, in plan mode |
A context object is `{label, content(question)}`. The label appears in the
transcript under the user turn and in the input palette's title; `content()`
wraps the question with the attachment.
## Selection payload
`selectionContext()` computes 1-based line numbers by counting newlines before
the selection, and fences the text with the document's syntax name:
```
<question>
From `src/parser.js` lines 1024:
```javascript
<selected text>
```
```
Controlled by `claudenova.includeSelection`.
## Prompting
`showInputPalette` (not `showInputPanel`) — it is the palette-style single-line
field, consistent with Nova's other quick entry points. Nova has no multi-line
input available to extensions; for long-form input the pattern used elsewhere in
this extension is to open a scratch document instead (see [plan mode](plan-mode.md)).
-60
View File
@@ -1,60 +0,0 @@
# Conversation model
**Status:** Implemented · **Code:** `Scripts/session.js`
`Session` consumes CLI messages and holds everything the views render: the
ordered transcript, tool activity, changed files, pending approvals, and running
cost. Views subscribe via `session.onChange`.
## Entries
`session.entries` is an ordered array of:
| Kind | Fields |
| --- | --- |
| `user` | `text`, `context` (the label of any attached selection) |
| `assistant` | `messageId`, `blocks[]` of `{type: "text" \| "thinking", text}` |
| `tool` | `id`, `name`, `input`, `summary`, `filePath`, `state`, `result` |
| `notice` | `text`, `tone` (`info` \| `error`) |
Tool `state` moves `running → done | error`.
## The assistant-message merge
**One assistant message id arrives several times.** The CLI emits a separate
`assistant` message per *completed content block* — one carrying `thinking`,
then another carrying `text` — and with `--include-partial-messages` the same id
also arrives as `stream_event` deltas. They are all one bubble.
`_assistantEntry(messageId)` keeps a map from message id to entry so every
delivery merges into the same entry. Two rules make it correct:
1. If the entry was streamed (`entry.streamed`), the authoritative `assistant`
message's text/thinking blocks are **skipped** — they would duplicate content
already assembled from deltas. Tool-use blocks are still registered.
2. `_handleAssistant` sets `liveAssistant` to the entry rather than clearing it.
Clearing it was a real bug: the authoritative thinking message arrived
mid-message, cleared the pointer, and every subsequent text delta was
dropped — the reply rendered empty.
The map is cleared on `result`, which bounds it to one turn.
## Changed files
Only recorded when an editing tool's `tool_result` comes back **without**
`is_error`. Recording optimistically on tool use was wrong: a denied write never
touched the file, but still appeared in the Changed Files list.
`tool_use_result.additions` / `.deletions` supply the counts when present.
## Ignored message types
`rate_limit_event`, `system:status`, `system:thinking_tokens`,
`system:commands_changed`, `system:mcp_status` carry nothing renderable.
`system:permission_denied` becomes an error notice.
## Usage
`result` carries `total_cost_usd` (cumulative for the session, not per turn — read
the latest, never sum) and `usage`. Input tokens are summed across
`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`.
+21 -21
View File
@@ -1,31 +1,31 @@
# IDE bridge
**Status: Proposed — not built.** This is a design note, not a description of
**Status: Next — not built.** This is a design note, not a description of
shipped behaviour.
This is now the main roadmap item. With chat in a terminal, the bridge is what
gives that session access to Nova: the selection, the open files, and diffs
rendered for review in the editor instead of as terminal text.
> Provenance: the protocol details below come from
> [coder/claudecode.nvim](https://github.com/coder/claudecode.nvim)'s
> `PROTOCOL.md`, which documents a working reimplementation of the same
> integration the VS Code extension uses. **None of it has been verified against
> the CLI by this project** — unlike the stdio protocol in
> [session transport](session-transport.md), which has. Treat every claim here
> as needing a first-run check.
> the CLI by this project.** Treat every claim here as needing a first-run
> check. (The stdio protocol the extension used previously *was* verified; that
> work is in commit `89d1a06`.)
## What it would add
Today the extension **drives** Claude: it spawns `claude --print` and owns the
session. The IDE bridge inverts that — Nova becomes a **server** that a `claude`
process running in a terminal connects to, so the terminal session can see the
editor:
Nova becomes a **server** that the `claude` process running in your terminal
connects to, so that session can see the editor:
- the current selection and open files as context
- diffs pushed into Nova for review rather than rendered as terminal text
- "open this file at this line" from Claude
The two modes are complementary, not competing. Driving is better for an
in-editor chat surface; the bridge is better if you live in the terminal — and
it gets the rich Claude Code TUI for free, which is the one thing Nova's UI
cannot render well.
The extension already launches the terminal session (see
[launching](launching.md)); the bridge is the return path.
## How discovery works
@@ -61,7 +61,7 @@ Messages are JSON-RPC 2.0 over RFC 6455 WebSocket.
| `getWorkspaceFolders` | ✅ `nova.workspace.path` |
| `checkDocumentDirty` | ✅ `document.isDirty` |
| `saveDocument` | ✅ `editor.save()` |
| `openDiff` | ⚠️ No diff viewer API. Reuse the editable-proposal pattern from [tool approvals](tool-approvals.md): stage the proposed file, open it, and map save → `FILE_SAVED`, close → `DIFF_REJECTED`. The tool blocks until one of those. |
| `openDiff` | ⚠️ No diff viewer API. Reuse the editable-proposal pattern: stage the proposed file, open it, and map save → `FILE_SAVED`, close → `DIFF_REJECTED`. The tool blocks until one of those. Working code for this — including change reconstruction and a unified diff renderer — is in commit `89d1a06` (`Scripts/diff.js`, `Scripts/permissions.js`). |
| `close_tab` | ❌ Nova cannot close tabs programmatically |
| `closeAllDiffTabs` | ❌ same |
| `getDiagnostics` | ❌ An extension can only read its own `IssueCollection`. Claude running the linter itself via Bash is the workaround. |
@@ -78,8 +78,8 @@ sidecar** spawned via `Process`, with the extension and sidecar talking over
stdio — `Process` supports `stdio: "jsonrpc"`, which is a natural fit here since
the payloads are already JSON-RPC.
That means Node 18+ becomes a runtime requirement, which v1 deliberately avoided.
Gate it behind a setting that is off by default.
That means Node 18+ becomes a runtime requirement, which the extension has so
far avoided. Gate it behind a setting that is off by default.
```
Nova extension ──jsonrpc/stdio──> node sidecar ──ws──> claude (terminal)
@@ -94,10 +94,10 @@ to it** — commands can only be placed in the `editor`, `extensions`, `text` an
`command-palette` menus, and there is no terminal object in the API surface.
So the realistic shape is: the extension runs the sidecar and publishes the lock
file; the user opens Nova's terminal themselves and runs `claude`. Discovery is
automatic from there — the CLI finds the lock file without being told. A
`claudenova.copyTerminalCommand` helper could put the right `env` prefix on the
clipboard for shells that do not inherit it.
file; you paste the launch command into Nova's terminal as you already do.
Discovery is automatic from there — the CLI finds the lock file without being
told. If the environment variables do not reach the terminal, `launch.js` can
prefix them onto the copied command.
If Panic ever exposes a terminal API, the only change is automating that last
step.
@@ -108,8 +108,8 @@ step.
accepted?
2. What does it do with tools that answer "unsupported" — degrade, or error the
turn?
3. Can the bridge and the driven session coexist in one window without the CLI
getting confused about which IDE it is attached to?
3. With several terminal sessions open in one project, do they all attach to the
same lock file — and does the editor need to distinguish them?
4. Lock-file lifecycle: who cleans up after a crash? (`pid` is in the file
precisely so stale entries can be detected.)
+50
View File
@@ -0,0 +1,50 @@
# Launching sessions
**Status:** Implemented · **Code:** `Scripts/launch.js`
Prepares the command that starts or resumes a Claude Code session and puts it on
the clipboard.
## Why the clipboard
**Nova exposes no terminal API to extensions.** Verified three ways: no terminal
type in `@types/nova-editor-node`, nothing in the API reference, and in the Nova
14 binary `newTerminal:` is an Objective-C menu selector with no JS bridge. An
extension cannot open a terminal tab, cannot target an existing one, and cannot
type into either.
The options were an external terminal app (a separate window, outside Nova) or
the clipboard. The clipboard keeps everything in Nova at the cost of one paste.
If Panic ever ships a terminal API, `copyCommand()` is the only function that
changes.
## The command
```
cd <workspace> && claude [--resume <id>] [--remote-control] [configured flags]
```
- `cd` is included so a terminal opened anywhere lands in the project.
- The executable is spelled `claude` when it resolves to a standard install
location (which interactive shells have on `PATH`) and as an absolute path
otherwise — a version-manager shim or a configured path is not guaranteed to
be on the shell's `PATH`.
- Configured flags come from settings: `--model`, `--effort`,
`--permission-mode`, plus `claudenova.extraArgs`. A launched session therefore
matches what the sidebar says it will be.
- Everything is escaped with `shellQuote()` — paths with spaces are common
enough to matter.
## Remote Control
`--remote-control` is offered as a separate action per session. It is the CLI's
own flag for driving a session from elsewhere; because the extension launches an
ordinary CLI session, nothing extra is needed to support it.
## Gotchas
- The notification is a courtesy — the clipboard write is the actual outcome, so
a failed notification is logged and ignored.
- Do not name a specific Nova menu path in user-facing copy; the terminal's
location in the menus has not been verified from here.
-69
View File
@@ -1,69 +0,0 @@
# Plan mode
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_reviewPlan`,
`_approvePlan`, `writeFeedback`, `sendFeedback`), `Scripts/main.js` (`commandPlan`)
Claude researches and proposes without changing anything, then presents a plan
for approval.
## Entering
`Plan with Claude…` (⌘⇧P) switches a **running** session to plan mode with the
`set_permission_mode` control request, so existing context carries into the
planning, then prompts. The permission mode can also be set to `plan` for a
whole session.
## The handoff
`ExitPlanMode` arrives as a `can_use_tool` request with
`requires_user_interaction: true`, carrying:
- `plan` — the plan as Markdown
- `planFilePath` — where Claude Code saved it (`~/.claude/plans/<slug>.md`)
The plan file is opened when it exists (it is the real artifact), otherwise the
`plan` text is shown in a scratch tab.
Announcement is a **`NotificationRequest`**, not an action panel: notifications
do not block the window, so the plan stays scrollable and editable while the
decision is open. Actions: `Approve`, `Auto-accept edits`, `Write Feedback…`.
`_reviewPlanFallback` uses a panel if notifications are unavailable.
## Approving
```jsonc
{"behavior":"allow",
"updatedPermissions":[{"type":"setMode","mode":"acceptEdits"|"default","destination":"session"}]}
```
The two approve options map onto Claude Code's own "auto-accept edits" and
"manually approve edits". The CLI answers with *"User has approved your plan.
You can now start coding."*
If the plan document was edited, `updatedInput.plan` carries the user's version
**and the CLI writes it to `planFilePath`**, making it the plan of record.
Verified: an edited plan was what Claude then followed.
## Three feedback channels
All combine into one `deny` message:
1. **Edit the plan document.** Unsaved editor content wins over disk
(`_currentPlanText` prefers an open editor over `readFile`).
2. **Highlight a passage** in the plan, then send feedback — the selection is
quoted back so "this part is wrong" has a referent (`_planSelection`).
3. **Write Feedback…** opens a scratch Markdown tab with a comment header.
Write freely, then `Send Feedback` in the sidebar. The header is stripped up
to the first `-->`.
Nova gives extensions no multi-line input field, so the editor *is* the text
box. The one-line `showInputPanel` remains only as a fallback when a scratch tab
cannot be opened.
Denying keeps the session in plan mode; Claude revises and re-presents.
## Gotchas
- Dismissing the notification leaves the plan pending in the sidebar with its
choices. Plans are expensive; never auto-deny on dismissal.
- `_showPlan` is also reachable from the `Open Plan` sidebar row.
-47
View File
@@ -1,47 +0,0 @@
# Questions
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_askQuestions`,
`_askOne`, `_askMultiple`)
When Claude needs a decision it calls `AskUserQuestion`, which arrives as a
`can_use_tool` request with `requires_user_interaction: true`.
## Input
```jsonc
{"questions":[{
"question":"Do you prefer tabs or spaces?",
"header":"Indentation",
"multiSelect":false,
"options":[{"label":"Tabs","description":"…"},{"label":"Spaces","description":"…"}]
}]}
```
## Answering
The host **rewrites the tool input**; there is no separate answer channel:
```jsonc
{"behavior":"allow",
"updatedInput":{input, "answers":{"<question text>":"<chosen label>"}}}
```
Keyed by the question's exact text. The CLI feeds it to the tool, which reports
back *"Your questions have been answered: …"*. Verified end to end: answering
`"Tabs"` produced a model reply of "You chose **Tabs**."
Multi-select answers are joined with `, `.
## Presentation
Each question becomes a `showChoicePalette` with `label — description` rows,
asked in sequence.
- **Single select** appends `Other…`, which opens an input panel for free text.
- **Multi select** toggles rows with a `✓` marker and appends
`Done — N selected`; with nothing selected that row is `Other…` instead.
- Dismissing a palette resolves `deny` with "The user dismissed the question
without answering" — matching Claude Code's own Escape behaviour.
Unit-covered: multi-select, free text, dismissal, and several questions in
sequence.
-39
View File
@@ -1,39 +0,0 @@
# Session history
**Status:** Implemented · **Code:** `Scripts/sessions-store.js`
`Resume Session…` lists this project's previous Claude Code sessions — including
ones started in a terminal — without shelling out to the interactive picker.
## Where Claude Code stores sessions
```
~/.claude/projects/<slug>/<session-id>.jsonl
```
The slug is the project path with `/` and `.` flattened to `-`:
```
/Users/me/Developer/git.example.ca/thing.novaextension
→ -Users-me-Developer-git-example-ca-thing-novaextension
```
`projectSlug()` implements exactly that substitution. Files are sorted by mtime,
newest first.
## Labels
`firstPrompt()` reads up to 40 lines of a transcript looking for the first
non-meta, non-synthetic `user` message and uses it as the picker label, falling
back to the short session id.
## Resuming
`--resume <id>` replaces `--session-id`. The session model is reset and rebuilt
from the resumed session's messages as they stream in.
## Gotchas
- Transcripts can be megabytes; `firstPrompt()` uses `readline()` and stops
early rather than reading whole files.
- Zero-byte and non-`.jsonl` entries are skipped.
-94
View File
@@ -1,94 +0,0 @@
# Session transport
**Status:** Implemented · **Code:** `Scripts/client.js`, `Scripts/cli.js`
Drives the `claude` executable in streaming JSON mode and owns the wire
protocol. Everything above this layer deals in parsed messages.
## Command line
```
claude --print
--input-format stream-json --output-format stream-json --verbose
--permission-prompt-tool stdio
[--include-partial-messages]
(--session-id <uuid> | --resume <id>)
[--model <alias>] [--effort <level>] [--permission-mode <mode>]
[--add-dir <path>]… [extra args]
```
`--permission-prompt-tool stdio` is **load-bearing and undocumented**. It does
not appear in `claude --help`; it was found in the Agent SDK bundle, which
passes it whenever a `canUseTool` callback is supplied. Without it the CLI has
nowhere to send permission prompts and **auto-denies every tool that needs one**
— the session appears to work and silently accomplishes nothing.
`--verbose` is required for `stream-json` output under `--print`.
We deliberately do **not** pass `--replay-user-messages`: it echoes our own user
turns back, which would double them in the transcript since we render them
locally on send.
## Protocol
Two kinds of traffic share stdout, distinguished by `type`.
**Session messages**`system` (`subtype: init` carries `session_id`, `model`,
`permissionMode`, `cwd`, `tools`), `assistant`, `user`, `stream_event`,
`result`. Also seen and ignored: `rate_limit_event`, `system:status`,
`system:thinking_tokens`, `keep_alive`, `transcript_mirror`.
**Control traffic** — request/response in both directions:
```jsonc
// out: we ask the CLI to do something
{"type":"control_request","request_id":"…","request":{"subtype":"initialize"}}
// in: the reply, matched by request_id
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{}}}
// in: the CLI asks us something (permission prompts)
{"type":"control_request","request_id":"…","request":{"subtype":"can_use_tool",}}
// out: our answer
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
// in: a prompt was withdrawn
{"type":"control_cancel_request","request_id":"…"}
```
Outbound control requests used: `initialize`, `interrupt`, `set_permission_mode`,
`set_model`. Inbound: `can_use_tool`. Any other inbound subtype is answered with
an error response rather than ignored.
**A control request must always be answered.** The CLI blocks on `can_use_tool`,
so a handler that throws — or silently returns — hangs the session forever.
`_handleControlRequest` wraps the handler and converts a throw into an error
response for exactly this reason.
## User input
```jsonc
{"type":"user","message":{"role":"user","content":"…"},"parent_tool_use_id":null}
```
`content` may be a string or an array of content blocks. No `session_id` is
required on input.
## Framing
`Process.onStdout` delivers chunks, not guaranteed whole lines, so `_onStdout`
buffers and splits on `\n`. Non-JSON lines are logged and skipped — the CLI
occasionally emits human-readable chatter.
## Binary resolution
`cli.js` looks in order: the configured path, `~/.local/bin/claude`,
`~/.claude/local/claude`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`,
then asks the user's login shell (`$SHELL -l -c 'command -v claude'`) — which is
what catches nvm, mise and asdf installs. The result is cached until the
`claudenova.binaryPath` setting changes.
## Gotchas
- `Process` stdin is a `WritableStream` that also has a convenience `write()`.
`processWriter()` in `util.js` handles both shapes.
- Never close stdin: the session stays open for streaming input.
- Exit status 143 is normal — it is SIGTERM from our own `stop()`.
+56
View File
@@ -0,0 +1,56 @@
# Sessions sidebar
**Status:** Implemented · **Code:** `Scripts/sidebar.js`, `Scripts/sessions-store.js`
Two sections: **Claude** (account, model, permission mode) and **Sessions** (the
project's conversations, newest first, each expanding into its actions).
```
Claude
Account [email protected] · Pro
Model Opus
Permissions Plan only
Sessions
▾ fix the parser bug 2h ago
Copy Resume Command
Copy Resume + Remote Control
Reveal Session File
```
## Where sessions come from
Claude Code stores one JSONL transcript per session:
```
~/.claude/projects/<slug>/<session-id>.jsonl
```
The slug is the project path with `/` and `.` flattened to `-`
(`/Users/me/git.example.ca/thing``-Users-me-git-example-ca-thing`). Files are
sorted by mtime; `firstPrompt()` reads up to 40 lines looking for the first
non-meta, non-synthetic user message to use as the row's label, falling back to
the short session id.
**Sessions started in a terminal appear here too** — it is the CLI's own store,
not something this extension maintains.
## Refreshing
Sessions live in `~/.claude`, **outside the workspace**, so `nova.fs.watch`
would never fire for them (and would cost a callback on every project file
change). Instead the list is rescanned when the Sessions section becomes
visible, plus on demand from the header's refresh button.
## Row plumbing
- `TreeItem.command` fires on **double-click**. A session row copies its resume
command; an action row runs its own action via `claudenova.sessionAction`,
which reads `sessionsView.selection[0]`.
- Session rows are `Collapsed` by default — with 25 rows, expanding all of them
would bury the list.
- `getParent()` returns the session for action rows so `reveal()` works.
## Gotchas
- `firstPrompt()` uses `readline()` and stops early: transcripts reach megabytes.
- Zero-byte and non-`.jsonl` entries are skipped.
-42
View File
@@ -1,42 +0,0 @@
# Sidebar
**Status:** Implemented · **Code:** `Scripts/sidebar.js`
Four TreeView sections under one sidebar (`claude`). Sections are declared in
`extension.json`; each is backed by a `TreeDataProvider` whose identifier matches
the section id.
| Section | Rows |
| --- | --- |
| `claude.session` | Status, Account, Model, Permissions, Usage |
| `claude.approvals` | Pending requests, each **expanded** with its choices as child rows |
| `claude.activity` | Tool calls, newest first, capped at 120 |
| `claude.changes` | Files Claude actually modified |
## Approvals are hierarchical
The parent row is the request; children are the actions from
`actionsFor(approval)` in `permissions.js`. Selecting a child and activating it
runs `claudenova.approvalAction`, which reads `approvalsView.selection[0]` and
calls `broker.decide(row.approval, row.id)`.
This is what makes a decision possible without any panel being open — dismissing
a notification never strands the session.
Action rows are generated, so they follow state: a plan shows
`Write Feedback…` until a feedback tab exists, then `Send Feedback`.
## Row plumbing
- `TreeItem.command` fires on **double-click**, not single click. Context menus
in `extension.json` mirror the same commands, gated on `contextValue`.
- `TreeItem.path` gives a file row its filetype icon and reveal behaviour.
- `getParent()` must return the parent for `reveal()` to work; action rows
return their approval.
- Reloads are throttled to 200ms — every streamed token changes the session.
## Labels
`friendlyToolName()` renames the interaction-surface tools: `ExitPlanMode`
`Plan`, `AskUserQuestion``Question`. `modeLabel()` maps permission modes;
note the CLI reports `default` for the mode its own flag calls `manual`.
-89
View File
@@ -1,89 +0,0 @@
# Tool approvals
**Status:** Implemented · **Code:** `Scripts/permissions.js`, `Scripts/diff.js`
Every `can_use_tool` control request becomes a decision surface. The request is
registered in the sidebar **first**, then surfaced — so dismissing a
notification or panel never strands the session.
## Routing
`_present(approval)` picks the surface:
| Request | Surface |
| --- | --- |
| `AskUserQuestion` | [Questions](questions.md) |
| `ExitPlanMode` | [Plan mode](plan-mode.md) |
| A file change (`preview` reconstructed) | Editable proposal, below |
| Other `requires_user_interaction` | Denied with an explanation |
| Anything else | Action panel (Allow / Allow for Session / Review / Deny) |
## `PermissionResult` shapes
```jsonc
{"behavior":"allow"}
{"behavior":"allow","updatedInput":{}} // run with different input
{"behavior":"allow","updatedPermissions":[{}]} // + change rules or mode
{"behavior":"deny","message":"…"} // message is shown to Claude
```
`updatedPermissions` entries used here: `{type:"addRules", rules:[{toolName}],
behavior:"allow", destination:"session"}` for *Allow for Session*, and
`{type:"setMode", mode, destination:"session"}` for plan approval.
## Editable proposals
The claudecode.nvim lesson: a proposal should be a **document you can amend**,
not a yes/no gate. When Claude wants to edit or create a file:
1. `diff.proposedChange()` reconstructs the file **as it would be after** the
change (applying `old_string``new_string`, or the `edits[]` in order).
2. That content is staged to
`<storage>/proposed/<last-8-of-request-id>/<basename>` and opened.
3. Editor gestures settle it:
| Gesture | Result |
| --- | --- |
| Save (`onDidSave`) | Apply — with any edits the user made |
| Close (`onDidDestroy`) | Deny |
A notification and the sidebar rows carry the same choices.
**Why a staged real file, not an untitled document:** an untitled document
cannot be saved without a Save dialog, which would break the gesture. Keeping
the original file extension also preserves syntax highlighting. Each request
stages into its own directory so two files with the same basename can be under
review at once while the tab still reads `index.js`. Stale proposals are cleared
on new session (`Controller.clearStagedProposals`).
**Expressing an amendment.** `_inputForContent()` rewrites the tool input as a
whole-file replacement rather than trying to recover a minimal edit:
| Tool | Amended input |
| --- | --- |
| `Write` | `{…input, content}` |
| `Edit` / `Update` | `{…input, old_string: <entire original file>, new_string: <content>, replace_all: false}` |
| `MultiEdit` | `{…input, edits:[{old_string: <entire original file>, new_string: <content>}]}` |
The whole original file is unique within itself by construction, so `old_string`
always matches exactly once — and the result is precisely what the user saw.
Set `claudenova.reviewEdits` to `panel` for the older quick yes/no.
## Diff rendering
`diff.js` is an LCS line diff with common prefix/suffix trimming and a
1,000,000-cell guard beyond which it degrades to a plain replacement. Rendered
unified with 3 lines of context, capped at 400 lines. `splitLines` drops the
final empty element after a trailing newline — otherwise every diff carried a
phantom blank line and a new file showed `-1`.
## Gotchas
- `requires_user_interaction: true` does **not** mean "cannot be handled" — it
means one-tap approve/deny is wrong because the tool's own card is the
interaction surface. Both tools that set it are handled natively; blanket
denying it (the first implementation) made plan mode a dead end.
- Sidebar `allow`/`deny` must not bypass the plan and question flows — a bare
allow on a plan would skip the `setMode` update and leave the session stuck in
plan mode. `decide()` routes those two back through `_present()`.
-45
View File
@@ -1,45 +0,0 @@
# Transcript
**Status:** Implemented · **Code:** `Scripts/transcript.js`
Nova sidebars hold only tree rows, so the readable conversation lives in an
editor tab as Markdown.
## Where it lives
`<workspaceStoragePath>/Claude Transcript.md` (global storage when the window
has no workspace). A real file rather than an untitled document so the tab has a
name, survives restarts, and can be saved without a dialog.
## Incremental rendering
`build()` produces the whole document; `_render()` diffs it against the previous
render, finds the common prefix, and replaces only the differing tail:
```js
edit.replace(new Range(prefix, current.length), text.slice(prefix));
```
Because output is append-mostly, the common prefix is usually the entire previous
text — one small edit at the end. This is what keeps scroll position and
selection stable while tokens stream in. Renders are throttled to 140ms;
`flush()` forces one and saves.
The viewport is only auto-scrolled when the caret was already at the end, so a
user reading earlier history is not yanked around.
## Format notes
- Tool results are fenced and truncated to 12 lines / 1200 chars.
- `Read` results are replaced by `<sub>read N lines</sub>` — echoing file
contents Claude just read buries the conversation.
- Fences widen past any backtick run in the body, so fenced content can't break
out.
- Thinking is rendered as a blockquote, controlled by `claudenova.showThinking`.
## Gotchas
- The document is user-editable. `_render()` diffs against the *document's*
current text, not a cached copy, so manual edits are overwritten rather than
corrupting the diff.
- `editor.onDidDestroy` clears the cached editor; the next `show()` reopens it.