Move to a terminal driven integration to have better interactions
This commit is contained in:
+31
-20
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
**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.)
|
||||
|
||||
|
||||
@@ -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 [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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user