First version

This commit is contained in:
2026-08-11 12:38:02 -03:00
parent 42e875d866
commit 89d1a06204
37 changed files with 5660 additions and 4 deletions
+29
View File
@@ -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.
+47
View File
@@ -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.
+40
View File
@@ -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 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
@@ -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`.
+122
View File
@@ -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.
+69
View File
@@ -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.
+47
View File
@@ -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.
+39
View File
@@ -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.
+94
View File
@@ -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()`.
+42
View File
@@ -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`.
+89
View File
@@ -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()`.
+45
View File
@@ -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.