First version
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Feature documentation
|
||||
|
||||
One file per capability. Each records what the feature does, how it is wired,
|
||||
the protocol facts it depends on, and the traps found while building it — so a
|
||||
future session does not have to re-derive them.
|
||||
|
||||
| Feature | Status | Code |
|
||||
| --- | --- | --- |
|
||||
| [Session transport](session-transport.md) | Implemented | `Scripts/client.js` |
|
||||
| [Conversation model](conversation-model.md) | Implemented | `Scripts/session.js` |
|
||||
| [Transcript](transcript.md) | Implemented | `Scripts/transcript.js` |
|
||||
| [Sidebar](sidebar.md) | Implemented | `Scripts/sidebar.js` |
|
||||
| [Tool approvals](tool-approvals.md) | Implemented | `Scripts/permissions.js`, `Scripts/diff.js` |
|
||||
| [Plan mode](plan-mode.md) | Implemented | `Scripts/permissions.js` |
|
||||
| [Questions](questions.md) | Implemented | `Scripts/permissions.js` |
|
||||
| [Authentication](authentication.md) | Implemented | `Scripts/auth.js`, `Scripts/cli.js` |
|
||||
| [Context and prompting](context.md) | Implemented | `Scripts/main.js` |
|
||||
| [Session history](session-history.md) | Implemented | `Scripts/sessions-store.js` |
|
||||
| [IDE bridge](ide-bridge.md) | **Proposed — not built** | — |
|
||||
|
||||
## The one-paragraph architecture
|
||||
|
||||
The extension spawns one `claude` process per window and speaks its
|
||||
newline-delimited JSON protocol over stdio. Session messages (assistant text,
|
||||
tool calls, results) and control traffic (the initialize handshake, permission
|
||||
prompts, interrupts) share that pipe. `client.js` owns the framing; `session.js`
|
||||
turns messages into a conversation model; `transcript.js`, `sidebar.js` and
|
||||
`permissions.js` are views over that model. Nothing else runs — no Node sidecar,
|
||||
no bundled copy of Claude Code, no network access of the extension's own.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Authentication
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/auth.js`, `Scripts/cli.js`
|
||||
|
||||
The extension never implements an OAuth flow and never sees credentials. It
|
||||
drives Claude Code's own commands.
|
||||
|
||||
> **Policy constraint.** Anthropic's Agent SDK terms state that third-party
|
||||
> developers may not offer claude.ai login in their products. Delegating to
|
||||
> `claude auth login` is the compliant path *and* the robust one — credentials
|
||||
> land where Claude Code expects them. Do not reimplement the PKCE flow here.
|
||||
|
||||
## Status
|
||||
|
||||
`claude auth status --json` →
|
||||
|
||||
```json
|
||||
{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty",
|
||||
"email":"…","orgId":"…","orgName":"…","subscriptionType":"pro"}
|
||||
```
|
||||
|
||||
`describe(state)` renders the sidebar's one-liner ("you@example.com · Pro").
|
||||
|
||||
## Sign-in
|
||||
|
||||
`claude auth login [--claudeai | --console]` is spawned with piped stdio. The
|
||||
extension scans stdout/stderr for an authorization URL, opens it with
|
||||
`nova.openURL`, then prompts for the code and writes it to the child's stdin.
|
||||
If no URL appears within 20s the attempt is abandoned with a message pointing at
|
||||
the terminal.
|
||||
|
||||
`signInViaTerminal()` is the fallback for SSO or device prompts: it writes a
|
||||
`sign-in-to-claude.command` script to global storage, `chmod +x`, and `open`s it
|
||||
so a real terminal runs `claude auth login`.
|
||||
|
||||
## API key alternative
|
||||
|
||||
Stored with `nova.credentials` (service `unsupervised.claudenova`, account
|
||||
`ANTHROPIC_API_KEY`) and injected into the spawned process environment by
|
||||
`cli.environment()`, where it takes precedence over the signed-in account.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `cli.environment()` reads the keychain on every spawn. Cheap, but it means
|
||||
keychain failures must be non-fatal — they are logged and ignored.
|
||||
- Nova's task environment can be sparse; `environment()` fills in a default
|
||||
`PATH` and `HOME` so the CLI can find its own tooling.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Context and prompting
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/main.js`
|
||||
|
||||
## Asking
|
||||
|
||||
| Command | Shortcut | Context attached |
|
||||
| --- | --- | --- |
|
||||
| `claudenova.ask` | ⌘⇧L | The active editor's selection, if any |
|
||||
| `claudenova.askAboutSelection` | ⌃⌘L | Required — warns when nothing is selected |
|
||||
| `claudenova.addFileContext` | — | The active file's path |
|
||||
| `claudenova.addFiles` | — | Files/folders chosen from `showFileChooser` |
|
||||
| `claudenova.plan` | ⌘⇧P | As `ask`, in plan mode |
|
||||
|
||||
A context object is `{label, content(question)}`. The label appears in the
|
||||
transcript under the user turn and in the input palette's title; `content()`
|
||||
wraps the question with the attachment.
|
||||
|
||||
## Selection payload
|
||||
|
||||
`selectionContext()` computes 1-based line numbers by counting newlines before
|
||||
the selection, and fences the text with the document's syntax name:
|
||||
|
||||
```
|
||||
<question>
|
||||
|
||||
From `src/parser.js` lines 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)).
|
||||
@@ -0,0 +1,60 @@
|
||||
# Conversation model
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/session.js`
|
||||
|
||||
`Session` consumes CLI messages and holds everything the views render: the
|
||||
ordered transcript, tool activity, changed files, pending approvals, and running
|
||||
cost. Views subscribe via `session.onChange`.
|
||||
|
||||
## Entries
|
||||
|
||||
`session.entries` is an ordered array of:
|
||||
|
||||
| Kind | Fields |
|
||||
| --- | --- |
|
||||
| `user` | `text`, `context` (the label of any attached selection) |
|
||||
| `assistant` | `messageId`, `blocks[]` of `{type: "text" \| "thinking", text}` |
|
||||
| `tool` | `id`, `name`, `input`, `summary`, `filePath`, `state`, `result` |
|
||||
| `notice` | `text`, `tone` (`info` \| `error`) |
|
||||
|
||||
Tool `state` moves `running → done | error`.
|
||||
|
||||
## The assistant-message merge
|
||||
|
||||
**One assistant message id arrives several times.** The CLI emits a separate
|
||||
`assistant` message per *completed content block* — one carrying `thinking`,
|
||||
then another carrying `text` — and with `--include-partial-messages` the same id
|
||||
also arrives as `stream_event` deltas. They are all one bubble.
|
||||
|
||||
`_assistantEntry(messageId)` keeps a map from message id to entry so every
|
||||
delivery merges into the same entry. Two rules make it correct:
|
||||
|
||||
1. If the entry was streamed (`entry.streamed`), the authoritative `assistant`
|
||||
message's text/thinking blocks are **skipped** — they would duplicate content
|
||||
already assembled from deltas. Tool-use blocks are still registered.
|
||||
2. `_handleAssistant` sets `liveAssistant` to the entry rather than clearing it.
|
||||
Clearing it was a real bug: the authoritative thinking message arrived
|
||||
mid-message, cleared the pointer, and every subsequent text delta was
|
||||
dropped — the reply rendered empty.
|
||||
|
||||
The map is cleared on `result`, which bounds it to one turn.
|
||||
|
||||
## Changed files
|
||||
|
||||
Only recorded when an editing tool's `tool_result` comes back **without**
|
||||
`is_error`. Recording optimistically on tool use was wrong: a denied write never
|
||||
touched the file, but still appeared in the Changed Files list.
|
||||
|
||||
`tool_use_result.additions` / `.deletions` supply the counts when present.
|
||||
|
||||
## Ignored message types
|
||||
|
||||
`rate_limit_event`, `system:status`, `system:thinking_tokens`,
|
||||
`system:commands_changed`, `system:mcp_status` carry nothing renderable.
|
||||
`system:permission_denied` becomes an error notice.
|
||||
|
||||
## Usage
|
||||
|
||||
`result` carries `total_cost_usd` (cumulative for the session, not per turn — read
|
||||
the latest, never sum) and `usage`. Input tokens are summed across
|
||||
`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`.
|
||||
@@ -0,0 +1,122 @@
|
||||
# IDE bridge
|
||||
|
||||
**Status: Proposed — not built.** This is a design note, not a description of
|
||||
shipped behaviour.
|
||||
|
||||
> Provenance: the protocol details below come from
|
||||
> [coder/claudecode.nvim](https://github.com/coder/claudecode.nvim)'s
|
||||
> `PROTOCOL.md`, which documents a working reimplementation of the same
|
||||
> integration the VS Code extension uses. **None of it has been verified against
|
||||
> the CLI by this project** — unlike the stdio protocol in
|
||||
> [session transport](session-transport.md), which has. Treat every claim here
|
||||
> as needing a first-run check.
|
||||
|
||||
## What it would add
|
||||
|
||||
Today the extension **drives** Claude: it spawns `claude --print` and owns the
|
||||
session. The IDE bridge inverts that — Nova becomes a **server** that a `claude`
|
||||
process running in a terminal connects to, so the terminal session can see the
|
||||
editor:
|
||||
|
||||
- the current selection and open files as context
|
||||
- diffs pushed into Nova for review rather than rendered as terminal text
|
||||
- "open this file at this line" from Claude
|
||||
|
||||
The two modes are complementary, not competing. Driving is better for an
|
||||
in-editor chat surface; the bridge is better if you live in the terminal — and
|
||||
it gets the rich Claude Code TUI for free, which is the one thing Nova's UI
|
||||
cannot render well.
|
||||
|
||||
## How discovery works
|
||||
|
||||
1. Serve WebSocket on a random port, bound to loopback.
|
||||
2. Write `~/.claude/ide/<port>.lock` (or `$CLAUDE_CONFIG_DIR/ide/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"pid": 1234,
|
||||
"workspaceFolders": ["/path/to/project"],
|
||||
"ideName": "Nova",
|
||||
"transport": "ws",
|
||||
"authToken": "<32-char lowercase hex, 128 bits from a CSPRNG>"
|
||||
}
|
||||
```
|
||||
|
||||
3. Export `CLAUDE_CODE_SSE_PORT=<port>` and `ENABLE_IDE_INTEGRATION=true` to the
|
||||
terminal where `claude` runs.
|
||||
4. Claude connects and must present
|
||||
`x-claude-code-ide-authorization: <authToken>` during the handshake; the
|
||||
server validates it against the lock file.
|
||||
|
||||
Messages are JSON-RPC 2.0 over RFC 6455 WebSocket.
|
||||
|
||||
## MCP tools to implement
|
||||
|
||||
| Tool | Nova feasibility |
|
||||
| --- | --- |
|
||||
| `openFile` | ✅ `nova.workspace.openFile(uri, {line, column})` |
|
||||
| `getCurrentSelection` | ✅ `activeTextEditor.selectedRange` |
|
||||
| `getLatestSelection` | ✅ track via `onDidChangeSelection` |
|
||||
| `getOpenEditors` | ✅ `nova.workspace.textEditors` |
|
||||
| `getWorkspaceFolders` | ✅ `nova.workspace.path` |
|
||||
| `checkDocumentDirty` | ✅ `document.isDirty` |
|
||||
| `saveDocument` | ✅ `editor.save()` |
|
||||
| `openDiff` | ⚠️ No diff viewer API. Reuse the editable-proposal pattern from [tool approvals](tool-approvals.md): stage the proposed file, open it, and map save → `FILE_SAVED`, close → `DIFF_REJECTED`. The tool blocks until one of those. |
|
||||
| `close_tab` | ❌ Nova cannot close tabs programmatically |
|
||||
| `closeAllDiffTabs` | ❌ same |
|
||||
| `getDiagnostics` | ❌ An extension can only read its own `IssueCollection`. Claude running the linter itself via Bash is the workaround. |
|
||||
| `executeCode` | ❌ No notebook/Jupyter API |
|
||||
|
||||
Returning a clear "unsupported" for the last four is fine — the VS Code
|
||||
extension's surface is a superset of what any given editor can do.
|
||||
|
||||
## The Nova-shaped problem
|
||||
|
||||
**Nova's JS runtime has no WebSocket** (`fetch`, Streams, `TextEncoder` yes;
|
||||
WebSocket no). So the server cannot live in the extension. It needs a **Node
|
||||
sidecar** spawned via `Process`, with the extension and sidecar talking over
|
||||
stdio — `Process` supports `stdio: "jsonrpc"`, which is a natural fit here since
|
||||
the payloads are already JSON-RPC.
|
||||
|
||||
That means Node 18+ becomes a runtime requirement, which v1 deliberately avoided.
|
||||
Gate it behind a setting that is off by default.
|
||||
|
||||
```
|
||||
Nova extension ──jsonrpc/stdio──> node sidecar ──ws──> claude (terminal)
|
||||
│ │
|
||||
└─ Nova API (editors, selection) ┘ ~/.claude/ide/<port>.lock
|
||||
```
|
||||
|
||||
## The terminal-tab angle
|
||||
|
||||
Nova has a built-in terminal, but **no extension API to create one or send text
|
||||
to it** — commands can only be placed in the `editor`, `extensions`, `text` and
|
||||
`command-palette` menus, and there is no terminal object in the API surface.
|
||||
|
||||
So the realistic shape is: the extension runs the sidecar and publishes the lock
|
||||
file; the user opens Nova's terminal themselves and runs `claude`. Discovery is
|
||||
automatic from there — the CLI finds the lock file without being told. A
|
||||
`claudenova.copyTerminalCommand` helper could put the right `env` prefix on the
|
||||
clipboard for shells that do not inherit it.
|
||||
|
||||
If Panic ever exposes a terminal API, the only change is automating that last
|
||||
step.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Does the CLI require the `ideName` to be on an allowlist, or is any string
|
||||
accepted?
|
||||
2. What does it do with tools that answer "unsupported" — degrade, or error the
|
||||
turn?
|
||||
3. Can the bridge and the driven session coexist in one window without the CLI
|
||||
getting confused about which IDE it is attached to?
|
||||
4. Lock-file lifecycle: who cleans up after a crash? (`pid` is in the file
|
||||
precisely so stale entries can be detected.)
|
||||
|
||||
## Effort
|
||||
|
||||
The WebSocket server, JSON-RPC plumbing, lock file and handshake are a
|
||||
well-defined chunk; the tool implementations are mostly one-liners against the
|
||||
Nova API. The risk is not the code — it is that the protocol is reverse
|
||||
engineered and unversioned, so it can change under us. claudecode.nvim tracking
|
||||
it successfully is good evidence it is stable enough in practice.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Plan mode
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_reviewPlan`,
|
||||
`_approvePlan`, `writeFeedback`, `sendFeedback`), `Scripts/main.js` (`commandPlan`)
|
||||
|
||||
Claude researches and proposes without changing anything, then presents a plan
|
||||
for approval.
|
||||
|
||||
## Entering
|
||||
|
||||
`Plan with Claude…` (⌘⇧P) switches a **running** session to plan mode with the
|
||||
`set_permission_mode` control request, so existing context carries into the
|
||||
planning, then prompts. The permission mode can also be set to `plan` for a
|
||||
whole session.
|
||||
|
||||
## The handoff
|
||||
|
||||
`ExitPlanMode` arrives as a `can_use_tool` request with
|
||||
`requires_user_interaction: true`, carrying:
|
||||
|
||||
- `plan` — the plan as Markdown
|
||||
- `planFilePath` — where Claude Code saved it (`~/.claude/plans/<slug>.md`)
|
||||
|
||||
The plan file is opened when it exists (it is the real artifact), otherwise the
|
||||
`plan` text is shown in a scratch tab.
|
||||
|
||||
Announcement is a **`NotificationRequest`**, not an action panel: notifications
|
||||
do not block the window, so the plan stays scrollable and editable while the
|
||||
decision is open. Actions: `Approve`, `Auto-accept edits`, `Write Feedback…`.
|
||||
`_reviewPlanFallback` uses a panel if notifications are unavailable.
|
||||
|
||||
## Approving
|
||||
|
||||
```jsonc
|
||||
{"behavior":"allow",
|
||||
"updatedPermissions":[{"type":"setMode","mode":"acceptEdits"|"default","destination":"session"}]}
|
||||
```
|
||||
|
||||
The two approve options map onto Claude Code's own "auto-accept edits" and
|
||||
"manually approve edits". The CLI answers with *"User has approved your plan.
|
||||
You can now start coding."*
|
||||
|
||||
If the plan document was edited, `updatedInput.plan` carries the user's version
|
||||
— **and the CLI writes it to `planFilePath`**, making it the plan of record.
|
||||
Verified: an edited plan was what Claude then followed.
|
||||
|
||||
## Three feedback channels
|
||||
|
||||
All combine into one `deny` message:
|
||||
|
||||
1. **Edit the plan document.** Unsaved editor content wins over disk
|
||||
(`_currentPlanText` prefers an open editor over `readFile`).
|
||||
2. **Highlight a passage** in the plan, then send feedback — the selection is
|
||||
quoted back so "this part is wrong" has a referent (`_planSelection`).
|
||||
3. **Write Feedback…** opens a scratch Markdown tab with a comment header.
|
||||
Write freely, then `Send Feedback` in the sidebar. The header is stripped up
|
||||
to the first `-->`.
|
||||
|
||||
Nova gives extensions no multi-line input field, so the editor *is* the text
|
||||
box. The one-line `showInputPanel` remains only as a fallback when a scratch tab
|
||||
cannot be opened.
|
||||
|
||||
Denying keeps the session in plan mode; Claude revises and re-presents.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Dismissing the notification leaves the plan pending in the sidebar with its
|
||||
choices. Plans are expensive; never auto-deny on dismissal.
|
||||
- `_showPlan` is also reachable from the `Open Plan` sidebar row.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Questions
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/permissions.js` (`_askQuestions`,
|
||||
`_askOne`, `_askMultiple`)
|
||||
|
||||
When Claude needs a decision it calls `AskUserQuestion`, which arrives as a
|
||||
`can_use_tool` request with `requires_user_interaction: true`.
|
||||
|
||||
## Input
|
||||
|
||||
```jsonc
|
||||
{"questions":[{
|
||||
"question":"Do you prefer tabs or spaces?",
|
||||
"header":"Indentation",
|
||||
"multiSelect":false,
|
||||
"options":[{"label":"Tabs","description":"…"},{"label":"Spaces","description":"…"}]
|
||||
}]}
|
||||
```
|
||||
|
||||
## Answering
|
||||
|
||||
The host **rewrites the tool input**; there is no separate answer channel:
|
||||
|
||||
```jsonc
|
||||
{"behavior":"allow",
|
||||
"updatedInput":{…input, "answers":{"<question text>":"<chosen label>"}}}
|
||||
```
|
||||
|
||||
Keyed by the question's exact text. The CLI feeds it to the tool, which reports
|
||||
back *"Your questions have been answered: …"*. Verified end to end: answering
|
||||
`"Tabs"` produced a model reply of "You chose **Tabs**."
|
||||
|
||||
Multi-select answers are joined with `, `.
|
||||
|
||||
## Presentation
|
||||
|
||||
Each question becomes a `showChoicePalette` with `label — description` rows,
|
||||
asked in sequence.
|
||||
|
||||
- **Single select** appends `Other…`, which opens an input panel for free text.
|
||||
- **Multi select** toggles rows with a `✓` marker and appends
|
||||
`Done — N selected`; with nothing selected that row is `Other…` instead.
|
||||
- Dismissing a palette resolves `deny` with "The user dismissed the question
|
||||
without answering" — matching Claude Code's own Escape behaviour.
|
||||
|
||||
Unit-covered: multi-select, free text, dismissal, and several questions in
|
||||
sequence.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Session history
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/sessions-store.js`
|
||||
|
||||
`Resume Session…` lists this project's previous Claude Code sessions — including
|
||||
ones started in a terminal — without shelling out to the interactive picker.
|
||||
|
||||
## Where Claude Code stores sessions
|
||||
|
||||
```
|
||||
~/.claude/projects/<slug>/<session-id>.jsonl
|
||||
```
|
||||
|
||||
The slug is the project path with `/` and `.` flattened to `-`:
|
||||
|
||||
```
|
||||
/Users/me/Developer/git.example.ca/thing.novaextension
|
||||
→ -Users-me-Developer-git-example-ca-thing-novaextension
|
||||
```
|
||||
|
||||
`projectSlug()` implements exactly that substitution. Files are sorted by mtime,
|
||||
newest first.
|
||||
|
||||
## Labels
|
||||
|
||||
`firstPrompt()` reads up to 40 lines of a transcript looking for the first
|
||||
non-meta, non-synthetic `user` message and uses it as the picker label, falling
|
||||
back to the short session id.
|
||||
|
||||
## Resuming
|
||||
|
||||
`--resume <id>` replaces `--session-id`. The session model is reset and rebuilt
|
||||
from the resumed session's messages as they stream in.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Transcripts can be megabytes; `firstPrompt()` uses `readline()` and stops
|
||||
early rather than reading whole files.
|
||||
- Zero-byte and non-`.jsonl` entries are skipped.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session transport
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/client.js`, `Scripts/cli.js`
|
||||
|
||||
Drives the `claude` executable in streaming JSON mode and owns the wire
|
||||
protocol. Everything above this layer deals in parsed messages.
|
||||
|
||||
## Command line
|
||||
|
||||
```
|
||||
claude --print
|
||||
--input-format stream-json --output-format stream-json --verbose
|
||||
--permission-prompt-tool stdio
|
||||
[--include-partial-messages]
|
||||
(--session-id <uuid> | --resume <id>)
|
||||
[--model <alias>] [--effort <level>] [--permission-mode <mode>]
|
||||
[--add-dir <path>]… [extra args]
|
||||
```
|
||||
|
||||
`--permission-prompt-tool stdio` is **load-bearing and undocumented**. It does
|
||||
not appear in `claude --help`; it was found in the Agent SDK bundle, which
|
||||
passes it whenever a `canUseTool` callback is supplied. Without it the CLI has
|
||||
nowhere to send permission prompts and **auto-denies every tool that needs one**
|
||||
— the session appears to work and silently accomplishes nothing.
|
||||
|
||||
`--verbose` is required for `stream-json` output under `--print`.
|
||||
|
||||
We deliberately do **not** pass `--replay-user-messages`: it echoes our own user
|
||||
turns back, which would double them in the transcript since we render them
|
||||
locally on send.
|
||||
|
||||
## Protocol
|
||||
|
||||
Two kinds of traffic share stdout, distinguished by `type`.
|
||||
|
||||
**Session messages** — `system` (`subtype: init` carries `session_id`, `model`,
|
||||
`permissionMode`, `cwd`, `tools`), `assistant`, `user`, `stream_event`,
|
||||
`result`. Also seen and ignored: `rate_limit_event`, `system:status`,
|
||||
`system:thinking_tokens`, `keep_alive`, `transcript_mirror`.
|
||||
|
||||
**Control traffic** — request/response in both directions:
|
||||
|
||||
```jsonc
|
||||
// out: we ask the CLI to do something
|
||||
{"type":"control_request","request_id":"…","request":{"subtype":"initialize"}}
|
||||
// in: the reply, matched by request_id
|
||||
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
|
||||
// in: the CLI asks us something (permission prompts)
|
||||
{"type":"control_request","request_id":"…","request":{"subtype":"can_use_tool",…}}
|
||||
// out: our answer
|
||||
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
|
||||
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
|
||||
// in: a prompt was withdrawn
|
||||
{"type":"control_cancel_request","request_id":"…"}
|
||||
```
|
||||
|
||||
Outbound control requests used: `initialize`, `interrupt`, `set_permission_mode`,
|
||||
`set_model`. Inbound: `can_use_tool`. Any other inbound subtype is answered with
|
||||
an error response rather than ignored.
|
||||
|
||||
**A control request must always be answered.** The CLI blocks on `can_use_tool`,
|
||||
so a handler that throws — or silently returns — hangs the session forever.
|
||||
`_handleControlRequest` wraps the handler and converts a throw into an error
|
||||
response for exactly this reason.
|
||||
|
||||
## User input
|
||||
|
||||
```jsonc
|
||||
{"type":"user","message":{"role":"user","content":"…"},"parent_tool_use_id":null}
|
||||
```
|
||||
|
||||
`content` may be a string or an array of content blocks. No `session_id` is
|
||||
required on input.
|
||||
|
||||
## Framing
|
||||
|
||||
`Process.onStdout` delivers chunks, not guaranteed whole lines, so `_onStdout`
|
||||
buffers and splits on `\n`. Non-JSON lines are logged and skipped — the CLI
|
||||
occasionally emits human-readable chatter.
|
||||
|
||||
## Binary resolution
|
||||
|
||||
`cli.js` looks in order: the configured path, `~/.local/bin/claude`,
|
||||
`~/.claude/local/claude`, `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`,
|
||||
then asks the user's login shell (`$SHELL -l -c 'command -v claude'`) — which is
|
||||
what catches nvm, mise and asdf installs. The result is cached until the
|
||||
`claudenova.binaryPath` setting changes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `Process` stdin is a `WritableStream` that also has a convenience `write()`.
|
||||
`processWriter()` in `util.js` handles both shapes.
|
||||
- Never close stdin: the session stays open for streaming input.
|
||||
- Exit status 143 is normal — it is SIGTERM from our own `stop()`.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Sidebar
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/sidebar.js`
|
||||
|
||||
Four TreeView sections under one sidebar (`claude`). Sections are declared in
|
||||
`extension.json`; each is backed by a `TreeDataProvider` whose identifier matches
|
||||
the section id.
|
||||
|
||||
| Section | Rows |
|
||||
| --- | --- |
|
||||
| `claude.session` | Status, Account, Model, Permissions, Usage |
|
||||
| `claude.approvals` | Pending requests, each **expanded** with its choices as child rows |
|
||||
| `claude.activity` | Tool calls, newest first, capped at 120 |
|
||||
| `claude.changes` | Files Claude actually modified |
|
||||
|
||||
## Approvals are hierarchical
|
||||
|
||||
The parent row is the request; children are the actions from
|
||||
`actionsFor(approval)` in `permissions.js`. Selecting a child and activating it
|
||||
runs `claudenova.approvalAction`, which reads `approvalsView.selection[0]` and
|
||||
calls `broker.decide(row.approval, row.id)`.
|
||||
|
||||
This is what makes a decision possible without any panel being open — dismissing
|
||||
a notification never strands the session.
|
||||
|
||||
Action rows are generated, so they follow state: a plan shows
|
||||
`Write Feedback…` until a feedback tab exists, then `Send Feedback`.
|
||||
|
||||
## Row plumbing
|
||||
|
||||
- `TreeItem.command` fires on **double-click**, not single click. Context menus
|
||||
in `extension.json` mirror the same commands, gated on `contextValue`.
|
||||
- `TreeItem.path` gives a file row its filetype icon and reveal behaviour.
|
||||
- `getParent()` must return the parent for `reveal()` to work; action rows
|
||||
return their approval.
|
||||
- Reloads are throttled to 200ms — every streamed token changes the session.
|
||||
|
||||
## Labels
|
||||
|
||||
`friendlyToolName()` renames the interaction-surface tools: `ExitPlanMode` →
|
||||
`Plan`, `AskUserQuestion` → `Question`. `modeLabel()` maps permission modes;
|
||||
note the CLI reports `default` for the mode its own flag calls `manual`.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Tool approvals
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/permissions.js`, `Scripts/diff.js`
|
||||
|
||||
Every `can_use_tool` control request becomes a decision surface. The request is
|
||||
registered in the sidebar **first**, then surfaced — so dismissing a
|
||||
notification or panel never strands the session.
|
||||
|
||||
## Routing
|
||||
|
||||
`_present(approval)` picks the surface:
|
||||
|
||||
| Request | Surface |
|
||||
| --- | --- |
|
||||
| `AskUserQuestion` | [Questions](questions.md) |
|
||||
| `ExitPlanMode` | [Plan mode](plan-mode.md) |
|
||||
| A file change (`preview` reconstructed) | Editable proposal, below |
|
||||
| Other `requires_user_interaction` | Denied with an explanation |
|
||||
| Anything else | Action panel (Allow / Allow for Session / Review / Deny) |
|
||||
|
||||
## `PermissionResult` shapes
|
||||
|
||||
```jsonc
|
||||
{"behavior":"allow"}
|
||||
{"behavior":"allow","updatedInput":{…}} // run with different input
|
||||
{"behavior":"allow","updatedPermissions":[{…}]} // + change rules or mode
|
||||
{"behavior":"deny","message":"…"} // message is shown to Claude
|
||||
```
|
||||
|
||||
`updatedPermissions` entries used here: `{type:"addRules", rules:[{toolName}],
|
||||
behavior:"allow", destination:"session"}` for *Allow for Session*, and
|
||||
`{type:"setMode", mode, destination:"session"}` for plan approval.
|
||||
|
||||
## Editable proposals
|
||||
|
||||
The claudecode.nvim lesson: a proposal should be a **document you can amend**,
|
||||
not a yes/no gate. When Claude wants to edit or create a file:
|
||||
|
||||
1. `diff.proposedChange()` reconstructs the file **as it would be after** the
|
||||
change (applying `old_string`→`new_string`, or the `edits[]` in order).
|
||||
2. That content is staged to
|
||||
`<storage>/proposed/<last-8-of-request-id>/<basename>` and opened.
|
||||
3. Editor gestures settle it:
|
||||
|
||||
| Gesture | Result |
|
||||
| --- | --- |
|
||||
| Save (`onDidSave`) | Apply — with any edits the user made |
|
||||
| Close (`onDidDestroy`) | Deny |
|
||||
|
||||
A notification and the sidebar rows carry the same choices.
|
||||
|
||||
**Why a staged real file, not an untitled document:** an untitled document
|
||||
cannot be saved without a Save dialog, which would break the gesture. Keeping
|
||||
the original file extension also preserves syntax highlighting. Each request
|
||||
stages into its own directory so two files with the same basename can be under
|
||||
review at once while the tab still reads `index.js`. Stale proposals are cleared
|
||||
on new session (`Controller.clearStagedProposals`).
|
||||
|
||||
**Expressing an amendment.** `_inputForContent()` rewrites the tool input as a
|
||||
whole-file replacement rather than trying to recover a minimal edit:
|
||||
|
||||
| Tool | Amended input |
|
||||
| --- | --- |
|
||||
| `Write` | `{…input, content}` |
|
||||
| `Edit` / `Update` | `{…input, old_string: <entire original file>, new_string: <content>, replace_all: false}` |
|
||||
| `MultiEdit` | `{…input, edits:[{old_string: <entire original file>, new_string: <content>}]}` |
|
||||
|
||||
The whole original file is unique within itself by construction, so `old_string`
|
||||
always matches exactly once — and the result is precisely what the user saw.
|
||||
|
||||
Set `claudenova.reviewEdits` to `panel` for the older quick yes/no.
|
||||
|
||||
## Diff rendering
|
||||
|
||||
`diff.js` is an LCS line diff with common prefix/suffix trimming and a
|
||||
1,000,000-cell guard beyond which it degrades to a plain replacement. Rendered
|
||||
unified with 3 lines of context, capped at 400 lines. `splitLines` drops the
|
||||
final empty element after a trailing newline — otherwise every diff carried a
|
||||
phantom blank line and a new file showed `-1`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `requires_user_interaction: true` does **not** mean "cannot be handled" — it
|
||||
means one-tap approve/deny is wrong because the tool's own card is the
|
||||
interaction surface. Both tools that set it are handled natively; blanket
|
||||
denying it (the first implementation) made plan mode a dead end.
|
||||
- Sidebar `allow`/`deny` must not bypass the plan and question flows — a bare
|
||||
allow on a plan would skip the `setMode` update and leave the session stuck in
|
||||
plan mode. `decide()` routes those two back through `_present()`.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Transcript
|
||||
|
||||
**Status:** Implemented · **Code:** `Scripts/transcript.js`
|
||||
|
||||
Nova sidebars hold only tree rows, so the readable conversation lives in an
|
||||
editor tab as Markdown.
|
||||
|
||||
## Where it lives
|
||||
|
||||
`<workspaceStoragePath>/Claude Transcript.md` (global storage when the window
|
||||
has no workspace). A real file rather than an untitled document so the tab has a
|
||||
name, survives restarts, and can be saved without a dialog.
|
||||
|
||||
## Incremental rendering
|
||||
|
||||
`build()` produces the whole document; `_render()` diffs it against the previous
|
||||
render, finds the common prefix, and replaces only the differing tail:
|
||||
|
||||
```js
|
||||
edit.replace(new Range(prefix, current.length), text.slice(prefix));
|
||||
```
|
||||
|
||||
Because output is append-mostly, the common prefix is usually the entire previous
|
||||
text — one small edit at the end. This is what keeps scroll position and
|
||||
selection stable while tokens stream in. Renders are throttled to 140ms;
|
||||
`flush()` forces one and saves.
|
||||
|
||||
The viewport is only auto-scrolled when the caret was already at the end, so a
|
||||
user reading earlier history is not yanked around.
|
||||
|
||||
## Format notes
|
||||
|
||||
- Tool results are fenced and truncated to 12 lines / 1200 chars.
|
||||
- `Read` results are replaced by `<sub>read N lines</sub>` — echoing file
|
||||
contents Claude just read buries the conversation.
|
||||
- Fences widen past any backtick run in the body, so fenced content can't break
|
||||
out.
|
||||
- Thinking is rendered as a blockquote, controlled by `claudenova.showThinking`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The document is user-editable. `_render()` diffs against the *document's*
|
||||
current text, not a cached copy, so manual edits are overwritten rather than
|
||||
corrupting the diff.
|
||||
- `editor.onDidDestroy` clears the cached editor; the next `show()` reopens it.
|
||||
Reference in New Issue
Block a user