A denser Claude Code status line and a pane of the files, links and folders this session produced, with a fuzzy filter and four palettes.

A denser Claude Code status line and a pane of the files, links and folders your session produced, with a fuzzy filter.

The status line, one row:
Opus 5.5 │ Coding │ main* +12 -3 │ ▪▪▪▪▪ 3 · 1h 20m

Opus 5.5 is the model.Coding is the directory the session runs in.main* is the branch, with * when there is uncommitted work, ↑2 ↓1 when it is ahead of or behind its upstream, and detached in red when there is no branch.+12 -3 is the lines added and removed against HEAD, shown only when the tree is dirty.▪▪▪▪▪ is the context window, one square per 20 %. It turns from calm to yellow to red as it fills.3 (after a file icon) is how many rows the Files pane holds: files, links and folders alike.1h 20m (after a clock icon) is how long the session has run.When the terminal is too narrow, segments drop in this order: model, directory, file count, churn, clock, branch, and the context bar last. The row never wraps.
The pane opens with /files or ctrl+x f. It is the session's hub: the files, links and folders this session produced, newest first, eight to a page, the newest 48 kept. Each row starts with its kind (▪ file, ↗ link, ▸ folder) and ends with a muted tail that says where it is: the file's folder, the link's host and port, the folder's parent.

On a terminal narrower than 110 columns the pane sits inline above the prompt and a page is as long as the rows it is given; the hint shrinks to one row:

Keys: Enter or the row's digit (1–8) opens the row; j/k move the selection and wrap around (k on the first row reaches the last entry of the last page, j on the last entry the first row); h/l turn the page and wrap likewise; o opens the folder the row sits in (a folder opens itself; a link has none); f finds: a field appears, the list narrows as you type (fuzzy over the name and the tail: the typed characters in order, gaps allowed), Enter opens the top match, Tab moves into the matches (walk them as above) and f brings you back to the field with your text kept, Esc returns to the list; Esc in the list closes the pane. The arrows and Tab also walk the selection, since a pane cannot bind them. A thing that opened takes you to another app and the pane closes behind it. When the newest file is a PNG, it is also drawn in the pane, in terminals that can show pictures (kitty and Ghostty); elsewhere a line says so. The preview hides while you filter.

What counts:
file:// link in a reply counts on the same lines a web link does (see below) and becomes the file or folder row it points at.gh pr create / gh issue create / gh release create, a deploy tool (mcp__*deploy*), and a dev server a command started (localhost, 127.0.0.1, a LAN address with a port; 0.0.0.0 is shown as localhost). Other URLs in command output (registry notices, docs) are not collected. And from a reply, as it streams: a link alone on its line (bare, a bullet, or text), or on a line that contains one of the open words — open, öffne, ansehen, view, review by default, the openWords option in the config menu.mkdir and git worktree add in a command (once it exists), and an absolute path alone on a reply line that is a directory.A file or folder that vanished drops off the list; a link is never checked. The status-line count is every row the pane holds.
plain.Inside Claude Code:
/plugin install cockpit --marketplace LolerHero/claude-cockpit
or from your shell: claude plugin marketplace add LolerHero/claude-cockpit, then claude plugin install cockpit@cockpit. That installs the Files pane and /files.
A plugin cannot set your status line or your keybindings, so one more step wires those up, from the copy the install made:
node ~/.claude/plugins/marketplaces/cockpit/setup.mjs # macOS, Linux
node "$env:USERPROFILE\.claude\plugins\marketplaces\cockpit\setup.mjs" # Windows PowerShell
Add --palette tokyo-night, --glyphs plain, --dry-run or --force as needed, then restart claude. The status line runs from that copy, which /plugin updates in place, so updates reach it too.
From source instead: git clone https://github.com/LolerHero/claude-cockpit, then node setup.mjs in the clone; setup then also loads the plugin from that folder. To stay on a release tested against your Claude Code build, clone its tag (git clone --branch v0.2.6 …); CHANGELOG.md names the build each release was tested on.
What setup changes, in ~/.claude (or CLAUDE_CONFIG_DIR):
settings.json → statusLine runs statusline/statusline.mjs. A status line you already have is kept unless you pass --force.settings.json → env.CLAUDE_CODE_PLUGIN_DIRS gains the folder, so the plugin loads in every session. Only from a source clone: installed through /plugin it loads already.keybindings.json → ctrl+x f opens /files, unless that chord is already bound to something else.settings.json is backed up next to itself before the first write. --dry-run prints the changes and writes nothing.
rose-pine (the default), catppuccin-mocha, tokyo-night and ansi. ansi uses your terminal's own 16 colors, so it follows whatever theme you run and works without truecolor.
The palette, the glyphs and the open words are options of the cockpit plugin in Claude Code's config menu. Change them there and the next redraw picks them up (openWords on the next session).
The mod runs inside Claude Code and writes the file count and the terminal width to ~/.claude/cockpit/<session id>.json, one small file per session, so two open sessions never mix their numbers. The status line is a separate Node process that reads its session's file, plus settings.json for your options. Both draw from palettes.js: the status line its colours, the Files pane its ground (base), with your terminal's own foreground on it.
Everything the mod in hooks/register.tsx reads, writes and runs. It makes no network requests and sends nothing anywhere; a link opens in your browser only when you pick it.
~/.claude/cockpit/<session id>.json (under CLAUDE_CONFIG_DIR if you set one), holding { "files": <count>, "columns": <terminal width> }. The status line script reads it to draw the count and to fit the row; the width comes from a hook on the row above the prompt (AbovePrompt), which measures it and draws nothing of its own. The path includes the session id, so it cannot be written as fixed text. Nothing else is written by the mod; setup.mjs, which you run yourself, edits settings.json and keybindings.json as listed under Install.CLAUDE_CONFIG_DIR, USERPROFILE and HOME to find the config folder for that file, and OS to tell Windows apart when opening something.classic.MessageDisplay) and on tool calls (tool.call, including Write and Edit) look for the files, links and folders to list, and pass every reply and call on unchanged. After a session switch the list is rebuilt from the same session's transcript. The list lives in the session's own state; nothing from the conversation leaves Claude Code.o for its folder) runs your system's opener with that file, folder or link: on Windows cmd /c start "" <path> for a file or folder and rundll32 url.dll,FileProtocolHandler <url> for a link, on macOS open <target>, on Linux xdg-open <target>. The target is built from the row, which is why the command cannot be written as fixed text.uname once, to tell macOS (open) from Linux (xdg-open)./files, which opens the pane. The pane's own keys work only while it is open.claude plugin validate .
claude plugin test .
node --test "test/*.test.mjs"
MIT
hooks/register.tsx 823 lines1// cockpit — a pane of what this session made, and the figures the status line needs.
2//
3// Opus 5.5 │ Coding │ main* │ ▪▪▪▪▪ 3 · 1h 20m ← status line
4//
5// 1 Open 2026-10-03-report.pdf ← the pane: /files, /shots, or ctrl+x f
6// 2 Open home-1440.png
7//
8// THE PANE: files a tool wrote or produced this session, newest first, one key each. Only things
9// a person opens (documents, images, archives) — never source, which belongs in the editor.
10// THE STATUS FILE: the status line is a separate Node process and cannot measure the terminal or
11// count files; this module publishes both to <config dir>/cockpit/<session id>.json on every change.
12//
13// The hint line under the prompt is left to the engine. Tried and dropped on 2.1.288: the hint
14// prop carries only the coaching text, the mode words are a pill no hook sees, and a rewritten
15// hint is drawn as its own element even when empty, between two separators.
16
17import { paletteOf } from '../palettes.js'
18import { atom, read, update } from 'claude-code'
19import type { Register, EngineInterface } from 'claude-code'
20
21import type { Doc, Entry, EntryKind } from '../types'
22
23const PANE = 'files'
24export const KEEP = 48
25// One page is what the dialog asks rows for, whatever the list holds: a dialog taller than the
26// terminal pushed every redraw (spinner ticks) into scrollback, stacking copies over the chat.
27export const PER_PAGE = 8
28const INLINE_MAX_BYTES = 400_000 // a tree carries bounded text; a big PNG gets the button alone
29// The hint is two rows: the walk (j k h n/N l) and the actions (o f, what Enter and Esc do).
30export const HINT_ROWS = 2
31export const IMAGE_ROWS = 10 // the inline preview's height in cells; `openPane` asks room for it
32// 8 rows + 2 hint rows + 10-row image and its gap = 21 rows: inside a normal window.
33
34// The key reaches the pane through `/files`, not a Button `action`: ~/.claude/keybindings.json
35// binds `ctrl+x f` to `command:files` in the Chat context (setup.mjs writes that binding).
36
37const files = atom({ plugin: 'cockpit', key: 'files' } as const, [])
38
39// Legacy rows (`{ path }`, before v0.2.0) are read as files: the state outlives an update of the module.
40const asEntry = (d: Entry | Doc): Entry =>
41 'target' in d ? d : { kind: 'file', target: d.path, label: d.label, tail: tailOf('file', d.path), at: d.at }
42
43// Trimmed on read, not only on the next write: a session that stored more under an older, larger
44// KEEP showed all of it until a new file came in.
45const stored = async ($: EngineInterface) => (await read($, files)).slice(-KEEP).map(asEntry)
46
47// Which page the pane shows and which row on it holds the focus ring. Module-level: a reload
48// starts on page 1, which is where a reopened pane starts anyway.
49let page = 0
50let focused = 0
51// Filter mode: `f` draws an Input (key `q`) whose text narrows the list; Esc returns to the list.
52let filtering = false
53let query = ''
54
55// Module-level: a reload starts at 0 until the next draw measures again; the status line then
56// falls back to its own width sources.
57let columns = 0
58
59// `$.env`, never `process.env`: the module runs in an environment of its own, with no Node.
60const configDir = async ($: EngineInterface) => {
61 const set = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => null)
62 if (set) return set
63 const home = (await $.env.get('USERPROFILE').catch(() => null)) ?? (await $.env.get('HOME').catch(() => null))
64 return home ? `${home}/.claude` : null
65}
66
67// One file per session (`cockpit/<session id>.json`, the id the status line's payload carries as
68// `session_id`), so two open sessions never draw each other's count or width. Never rejects: it
69// runs un-awaited from a render, where a rejection has no one to land on.
70// ponytail: one ~40-byte file per session, never pruned; prune by age if the folder ever matters.
71const publish = async ($: EngineInterface) => {
72 try {
73 const dir = await configDir($)
74 const id = await $.session.id()
75 if (!dir || !/^[\w-]+$/.test(id)) return
76 // Counts what is still on disk, and drops the rest from the list, so a deleted file leaves
77 // the count and the pane together. At most KEEP exists checks. Links are taken as given.
78 // The count is every row: a link or folder behind a 0 in the bar is one no one opens.
79 const docs = await stored($)
80 const gone = new Set<string>()
81 for (const e of docs) {
82 if (e.kind === 'link') continue
83 if (!(await $.fs.exists(e.target).catch(() => false))) gone.add(e.target)
84 }
85 if (gone.size) await update($, files, list => list.map(asEntry).filter(e => !gone.has(e.target)))
86 const fileCount = docs.filter(e => !gone.has(e.target)).length
87 await $.fs.write(`${dir}/cockpit/${id}.json`, JSON.stringify({ files: fileCount, columns }))
88 } catch {
89 // the status line falls back to its own width sources and omits the count
90 }
91}
92
93// ─── what this session made ──────────────────────────────────────────────────────────────────
94
95// Things a person opens, not things a person greps. Deliberately NOT .ts/.py/.json — source
96// belongs in the editor, and listing it buries the one file they wanted among forty imports.
97const OPENABLE = 'png|jpe?g|webp|gif|svg|pdf|docx?|xlsx?|pptx?|csv|md|html?|zip'
98
99// Absolute OR relative: Playwright answers `./shot.png`, and an absolute-only pattern silently
100// found nothing. The separator classes take a RUN, because JSON.stringify escapes a Windows path
101// and a single-separator branch eats the drive letter (`C:/Users/…` → `/Users/…`). Names take any
102// letter (`Rechnung-März.pdf`), not just ASCII. ponytail: a space ends a path in free text, so
103// `my report.pdf` in a command's output reads as `report.pdf`; the mtime check in `collect`
104// drops it unless the call produced that file. A Write never goes through this pattern.
105const FILE_PATH = new RegExp(
106 String.raw`(?:[A-Za-z]:[\\/]+|\.{0,2}[\\/]+)?[\p{L}\p{N}_.\-][\p{L}\p{N}_.\-\\/]*\.(?:${OPENABLE})`,
107 'giu',
108)
109const IS_OPENABLE = new RegExp(String.raw`\.(?:${OPENABLE})$`, 'i')
110// The engine spills an MCP result's image block to <session>/tool-results/mcp-<server>-blob-<n>-<id>.png
111// and names that copy in the result. It is a duplicate of the file the tool itself saved, kept in
112// the transcript's store: never something the person made. (Its 43-char name, cut to 40 by
113// `label`, is what showed up as `mcp-playwright-blob-…-g5ctsr` with no extension.)
114const ENGINE_BLOB = /(?:^|\/)mcp-[\w-]*-blob-\d+-\w+\.\w+$/
115
116const isAbsolute = (p: string) => /^(?:[A-Za-z]:\/|\/)/.test(p)
117
118export const pathsIn = (value: unknown, cwd?: string): string[] => {
119 const text = typeof value === 'string' ? value : JSON.stringify(value ?? '')
120 const found = (text.match(FILE_PATH) ?? []).map(p => p.replace(/[\\/]+/g, '/').replace(/^\.\//, ''))
121 const base = cwd?.replace(/\\/g, '/').replace(/\/+$/, '')
122 // De-duplicated AFTER normalising: one file named in several escapings (Playwright's link,
123 // comment and code) is one path, not three.
124 return [...new Set(found.map(p => (isAbsolute(p) || !base ? p : `${base}/${p}`)))].filter(
125 p => !ENGINE_BLOB.test(p),
126 )
127}
128
129// A relative path is tried under each root in turn, first that exists wins: the project root
130// first (Playwright MCP saves relative to it), then the cwd (a shell `cd` moves only that one).
131const resolve = async ($: EngineInterface, path: string, roots: string[]) => {
132 const tries = isAbsolute(path) || !roots.length
133 ? [path]
134 : roots.map(root => `${root.replace(/\\/g, '/').replace(/\/+$/, '')}/${path}`)
135 for (const full of tries) if (await $.fs.exists(full).catch(() => false)) return full
136 return null
137}
138
139const label = (path: string) => path.split('/').pop()?.slice(0, 40) ?? 'file'
140// One file, one spelling: Windows answers `e:\` from one call and `E:\` from another, and the
141// list took them for two files.
142const sameDrive = (path: string) => path.replace(/^([a-z]):/, (_, d: string) => `${d.toUpperCase()}:`)
143// What makes two spellings one file: a drive-letter path is Windows, where case and separator do not
144// count (`E:\work\Prep.md` is `e:/work/prep.md`). Elsewhere case counts. Links compare as written.
145const keyOf = (target: string) => (/^[A-Za-z]:/.test(target) ? target.replace(/\\/g, '/').toLowerCase() : target)
146// The folder a file sits in, for telling two files of one name apart.
147const folderOf = (path: string) => path.split('/').slice(-2, -1)[0] ?? ''
148const hostOf = (url: string) => url.replace(/^https?:\/\//, '').split(/[/?#]/)[0] ?? url
149const parentOf = (path: string) => path.split('/').slice(0, -1).join('/')
150// The muted tail of a row: where the thing is, in the words that tell two of a name apart.
151export const tailOf = (kind: EntryKind, target: string) =>
152 kind === 'file' ? folderOf(target) : kind === 'link' ? hostOf(target) : parentOf(target)
153const labelOf = (kind: EntryKind, target: string) =>
154 kind === 'link' ? target.replace(/^https?:\/\//, '').slice(0, 40) : label(target)
155// PNG only: the terminal's Image takes PNG data, and anything else refuses the WHOLE pane.
156const isPng = (path: string) => /\.png$/i.test(path)
157
158type Found = { kind: EntryKind; target: string }
159const link = (target: string): Found => ({ kind: 'link', target })
160// A `gh … create` prints the URL of the thing it made; any other Bash output's URLs are noise.
161const GH_CREATE = /\bgh\s+(?:pr|issue|release)\s+create\b/
162const DEPLOY_TOOL = /^mcp__.*deploy/i
163
164// What a tool answered, as text: `text` when the engine flattened it; a Bash result's stdout and
165// stderr; an MCP result's text blocks; else the result as JSON. Not JSON for the first three: a
166// stringified `\n` is a backslash and an `n`, which a URL pattern reads as part of the URL.
167const textOf = (ran: { text?: unknown; result?: unknown }): string => {
168 if (typeof ran.text === 'string') return ran.text
169 const r = ran.result as { stdout?: unknown; stderr?: unknown; content?: unknown } | undefined
170 if (r && (typeof r.stdout === 'string' || typeof r.stderr === 'string')) {
171 return `${typeof r.stdout === 'string' ? r.stdout : ''}\n${typeof r.stderr === 'string' ? r.stderr : ''}`
172 }
173 if (r && Array.isArray(r.content)) {
174 return r.content.map(b => (b && typeof b.text === 'string' ? b.text : '')).join('\n')
175 }
176 return JSON.stringify(ran.result ?? ran ?? '')
177}
178
179// The host starts with a letter, digit or `[`: a bare `https://` in prose is a word, not a link.
180// A backtick ends it, so `` `https://x` `` in a reply is `https://x`.
181const URL_RE = /https?:\/\/[\w[][^\s<>()"'\]`]*/g
182// A dev server: localhost, loopback, the unspecified address or a private IPv4, each with a port.
183const SERVER_RE =
184 /(?<![\w.:/])(?:https?:\/\/)?(localhost|127\.0\.0\.1|0\.0\.0\.0|10(?:\.\d{1,3}){3}|192\.168(?:\.\d{1,3}){2}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}):(\d{2,5})(\/[^\s<>()"']*)?/g
185const trimUrl = (u: string) => u.replace(/[.,;:!?)\]]+$/, '')
186// A line a person is meant to follow: a bare URL, `- url`, `[text](url)`, or one with an open word.
187const ALONE_RE = /^(?:[-*]\s+)?(?:\[[^\]]*\]\()?https?:\/\/\S+\)?$/
188
189// `tool`: every URL (an artifact publish, `gh … create`, a deploy tool). `servers`: dev-server
190// addresses only, so a build log's registry and docs links stay out; `0.0.0.0` is not a place a
191// browser can go, so it is written as `localhost`. `reply`: a line's URLs when the line is the
192// link alone or carries one of `openWords`. De-duplicated, in order of first appearance.
193export const linksIn = (text: string, mode: 'tool' | 'servers' | 'reply', openWords: string[] = []): string[] => {
194 const out: string[] = []
195 const words = openWords.map(w => w.toLowerCase())
196 for (const raw of text.split('\n')) {
197 const line = raw.trim()
198 if (mode === 'reply' && !ALONE_RE.test(line) && !words.some(w => line.toLowerCase().includes(w))) continue
199 if (mode !== 'servers') for (const u of line.match(URL_RE) ?? []) out.push(trimUrl(u))
200 for (const m of line.matchAll(SERVER_RE)) {
201 const host = m[1] === '0.0.0.0' ? 'localhost' : m[1]
202 out.push(trimUrl(`http://${host}:${m[2]}${m[3] ?? ''}`))
203 }
204 }
205 return [...new Set(out)]
206}
207
208const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
209// `command`: the paths `mkdir` and `git worktree add` name (quotes honoured, `-b <branch>` skipped).
210// `reply`: an absolute path alone on its line, backticks or not — a relative one is not taken, the
211// reply has no cwd of its own (Julian, 2026-10-04). Forward slashes, no trailing slash, de-duplicated.
212// ponytail: a `mkdir` behind `cd x &&` is taken relative to the session cwd, not `x`; `resolve`
213// drops it when it does not exist there.
214export const foldersIn = (text: string, source: 'command' | 'reply'): string[] => {
215 const out: string[] = []
216 if (source === 'reply') {
217 for (const raw of text.split('\n')) {
218 const m = /^\**`?((?:[A-Za-z]:[\\/]|\/)[^\s`*]*)`?\**$/.exec(raw.trim())
219 if (m?.[1]) out.push(norm(m[1]))
220 }
221 return [...new Set(out)]
222 }
223 for (const part of text.split(/&&|\|\||;|\|/)) {
224 const words = (part.trim().match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
225 if (words[0] === 'mkdir') out.push(...words.slice(1).filter(w => !w.startsWith('-')))
226 if (words[0] === 'git' && words[1] === 'worktree' && words[2] === 'add') {
227 const args: string[] = []
228 for (let i = 3; i < words.length; i++) {
229 const w = words[i] ?? ''
230 if (w === '-b' || w === '-B') i++ // the branch name, not a path
231 else if (!w.startsWith('-')) args.push(w)
232 }
233 if (args[0]) out.push(args[0])
234 }
235 }
236 return [...new Set(out.map(norm))]
237}
238
239// An absolute path alone on a reply line: an openable file is a file row, anything else a folder row
240// (collect drops it unless it is a directory). Julian, 2026-10-04: a file Claude lists is one to open.
241// 2026-10-05: so is an openable file in backticks anywhere on a line — that is how a path gets named
242// mid-sentence. Folders and bare prose paths still need a line of their own.
243const TICKED_FILE = new RegExp(String.raw`\x60((?:[A-Za-z]:[\\/]|/)[^\x60\n]*\.(?:${OPENABLE}))\x60`, 'giu')
244export const replyPaths = (text: string, openWords: string[] = []): Found[] =>
245 [...new Set([
246 ...foldersIn(text, 'reply'),
247 ...[...text.matchAll(TICKED_FILE)].map(m => norm(m[1] ?? '')),
248 ...fileLinksIn(text, openWords),
249 ])].map(target => ({
250 kind: IS_OPENABLE.test(target) ? 'file' as const : 'folder' as const,
251 target,
252 }))
253
254// A `file://` link in the reply, taken on the same lines as a web link (alone, `- url`, `[text](url)`,
255// or an open word) and turned into its path: `file:///E:/x` → `E:/x`, `%20` decoded.
256// ponytail: `file://host/share` (UNC) is not handled; no one has written one yet.
257const FILE_URL_RE = /file:\/\/([^\s<>()"'\]`]+)/g
258const FILE_ALONE_RE = /^(?:[-*]\s+)?(?:\[[^\]]*\]\()?`?file:\/\/\S+\)?$/
259const fileLinksIn = (text: string, openWords: string[]): string[] => {
260 const words = openWords.map(w => w.toLowerCase())
261 const out: string[] = []
262 for (const raw of text.split('\n')) {
263 const line = raw.trim()
264 if (!FILE_ALONE_RE.test(line) && !words.some(w => line.toLowerCase().includes(w))) continue
265 for (const m of line.matchAll(FILE_URL_RE)) {
266 let p = trimUrl(m[1] ?? '').replace(/`$/, '')
267 try {
268 p = decodeURIComponent(p)
269 } catch {
270 // a stray % — keep it as written
271 }
272 out.push(norm(p.replace(/^\/([A-Za-z]:)/, '$1')))
273 }
274 }
275 return out
276}
277
278// fzf-flavoured: every query char in order; +3 for continuing a run, +2 at a word start, +1 elsewhere.
279// ponytail: greedy leftmost positions, not the best alignment; good enough for 48 short rows.
280const WORD_START = /[\s/\-_.:]/
281export const fuzzyScore = (query: string, text: string): number | null => {
282 const q = query.toLowerCase()
283 const t = text.toLowerCase()
284 let score = 0
285 let from = 0
286 let prev = -2
287 for (const ch of q) {
288 const at = t.indexOf(ch, from)
289 if (at < 0) return null
290 score += at === prev + 1 ? 3 : at === 0 || WORD_START.test(t[at - 1] ?? '') ? 2 : 1
291 prev = at
292 from = at + 1
293 }
294 return score
295}
296// Best first, ties newest first; an empty query is the list newest first. Label and tail both count.
297export const fuzzy = <E extends { label: string; tail: string; at: number }>(query: string, entries: E[]): E[] =>
298 entries
299 .map(e => ({ e, s: fuzzyScore(query, `${e.label} ${e.tail}`) }))
300 .filter((x): x is { e: E; s: number } => x.s !== null)
301 .sort((a, b) => b.s - a.s || b.e.at - a.e.at)
302 .map(x => x.e)
303
304// The kind, one glyph a row: a file, a link out, a folder in.
305const MARK: Record<EntryKind, string> = { file: '▪', link: '↗', folder: '▸' }
306
307// A Button hotkey is ONE digit or ONE lowercase letter (the engine refuses anything else), and
308// it is the only key a pane can bind: the arrows and Tab belong to the engine, which walks the
309// focus ring with them. So rows take 1–8 and the navigation takes h/j/k/l, as Buttons of its own.
310const NAV = { j: 'nav:j', k: 'nav:k', h: 'nav:h', l: 'nav:l', o: 'nav:o', f: 'nav:f' } as const
311const isNav = (key: string | undefined) => !!key?.startsWith('nav:')
312
313// Turns to `to` (clamped by the caller), redraws, and puts the ring on row `row` of it.
314const go = async ($: EngineInterface, to: number, row: number) => {
315 page = to
316 focused = row
317 $.ui.invalidate('ui.render')
318 await $.ui.focus({ requestId: PANE, key: `row:${row}` }).catch(() => null)
319}
320
321// Top level, not nested in `register`: the validator only traces `$` into a function declared at
322// the top of the file, and refuses a module that hands it to a closure it cannot follow.
323// `since`: keep only files modified after it, so a path a command merely printed (`git status`
324// listing README.md) is not taken for one it produced. 2 s of slack for coarse file-system clocks.
325// Publishes even when nothing is collected: every tool call is where a deletion can have happened.
326// Files and folders must exist (a folder must be a directory); links are taken as given.
327const collect = async ($: EngineInterface, found: Found[], since?: number, roots: string[] = []) => {
328 const keep: Found[] = []
329 for (const f of found) {
330 if (f.kind === 'link') {
331 keep.push(f)
332 continue
333 }
334 const resolved = await resolve($, f.target, roots)
335 if (!resolved) continue
336 const target = sameDrive(resolved)
337 const stat = await $.fs.stat(target).catch(() => null)
338 if (f.kind === 'folder' && stat?.kind !== 'dir') continue
339 if (f.kind === 'file' && since !== undefined && (!stat || stat.mtimeMs < since - 2000)) continue
340 // One row a file: a rebuild hands every mention over in one batch. The last mention wins the slot.
341 const dup = keep.findIndex(k => keyOf(k.target) === keyOf(target))
342 if (dup >= 0) keep.splice(dup, 1)
343 keep.push({ kind: f.kind, target })
344 }
345 if (keep.length) {
346 const at = await $.clock.now()
347 await update($, files, list => {
348 // A target produced again moves to the newest slot: a regenerated file is this session's
349 // latest output, and left in its old place a full list drops it on the next new file.
350 const targets = new Set(keep.map(k => keyOf(k.target)))
351 const kept = list.map(asEntry).filter(e => !targets.has(keyOf(e.target)))
352 const fresh = keep.map(k => ({ ...k, label: labelOf(k.kind, k.target), tail: tailOf(k.kind, k.target), at }))
353 return [...kept, ...fresh].slice(-KEEP)
354 })
355 }
356 await publish($)
357}
358
359// The tools whose answer names a file they produced. Bash is collected live (its call time
360// separates produced from merely printed) but never replayed, where that time is gone.
361const SCREENSHOT_TOOLS = [
362 'mcp__playwright__browser_take_screenshot',
363 'mcp__claude-in-chrome__computer',
364 'mcp__plugin_figma_figma__get_screenshot',
365]
366
367// The list is host state for the session id; a trip through the agent screen hands the same
368// conversation a new id and an empty state. The transcript survives it, so an empty list is
369// rebuilt from it, oldest first so the newest call ends up on top: Writes, screenshots, artifact
370// publishes, and the links and folders the replies named. Never rejects.
371const rebuild = async ($: EngineInterface, openWords: string[]) => {
372 try {
373 const messages = await $.session.messages()
374 const roots = [await $.session.root().catch(() => ''), await $.session.cwd().catch(() => '')].filter(Boolean)
375 const found: Found[] = []
376 const file = (target: string): Found => ({ kind: 'file', target })
377 for (const m of messages) {
378 if (m.role === 'assistant' && m.text) {
379 found.push(...linksIn(m.text, 'reply', openWords).map(link))
380 found.push(...replyPaths(m.text, openWords))
381 }
382 for (const use of m.toolUses ?? []) {
383 if (use.isError) continue
384 if ((use.tool === 'Write' || use.tool === 'Edit') && typeof use.input?.file_path === 'string') {
385 const path = use.input.file_path.replace(/\\/g, '/')
386 if (IS_OPENABLE.test(path)) found.push(file(path))
387 } else if (use.tool === 'Artifact') {
388 found.push(...linksIn(use.text ?? JSON.stringify(use.result ?? ''), 'tool').map(link))
389 } else if (SCREENSHOT_TOOLS.includes(use.tool)) {
390 found.push(...pathsIn(use.result ?? use.text ?? '').map(file))
391 }
392 }
393 }
394 if (found.length) await collect($, found, undefined, roots)
395 } catch {
396 // no transcript to read (a fresh session): the list stays empty
397 }
398}
399
400// Open as a dialog: it takes the keyboard at once (so a hotkey works without `ctrl+x tab`), Esc
401// closes it, and toasts wait behind it rather than landing on the list being picked from.
402// `rows` is what makes the arrows walk the list: a dialog tall enough to show whole has nothing
403// to scroll, and while it has, the engine spends the arrows on scrolling (measured 2026-10-03:
404// opened a third tall, a 20-row image box made the list scroll and the arrows dead).
405// One page at most, never the whole list: see PER_PAGE.
406const openPane = async ($: EngineInterface, openWords: string[]) => {
407 // An empty list may be a new session id over an old conversation: rebuild it before opening.
408 // Here, not in the draw: the host refuses a state write while a render is dispatched.
409 if (!(await stored($)).length) await rebuild($, openWords)
410 const list = await stored($)
411 const newest = list[list.length - 1]
412 page = 0
413 focused = 0
414 filtering = false
415 query = ''
416 const rows = Math.min(PER_PAGE, Math.max(1, list.length)) + HINT_ROWS + (newest && isPng(newest.target) ? IMAGE_ROWS + 1 : 0)
417 await $.ui.open({ id: PANE, title: 'Files', focus: true, closeOnEscape: true, holdToasts: true, rows })
418}
419
420// The host's own opener, no shell. Windows: `cmd /c start "" <path>` — the empty string is
421// `start`'s title argument, without it a quoted path becomes the title; a URL goes through the
422// URL handler instead, because `cmd /c start` re-parses its line and an `&` in a query string
423// would split it. macOS: `open`. Linux: `xdg-open` (both take a URL as they take a path). A
424// failure says so: a silent dead press is the worst outcome for a button.
425const openerFor = async ($: EngineInterface, target: string): Promise<string[]> => {
426 const os = ((await $.env.get('OS').catch(() => null)) ?? '').toLowerCase()
427 const windows = os.includes('windows') || !!(await $.env.get('USERPROFILE').catch(() => null))
428 if (windows) {
429 return /^https?:\/\//.test(target)
430 ? ['rundll32', 'url.dll,FileProtocolHandler', target]
431 : ['cmd', '/c', 'start', '', target.replace(/\//g, '\\')]
432 }
433 const uname = await $.process.run(['uname'], { timeoutMs: 2000 }).catch(() => null)
434 return [(uname?.stdout ?? '').trim() === 'Darwin' ? 'open' : 'xdg-open', target]
435}
436
437// The `openWords` option: comma-separated, trimmed, case folded in `linksIn`.
438const wordsOf = (v: unknown) => String(v ?? '').split(',').map(w => w.trim()).filter(Boolean)
439
440export const register: Register = (on, options) => {
441 const openWords = wordsOf(options.openWords)
442 // ponytail: Button labels take the terminal's foreground (no colour prop), so a light terminal on a
443 // dark base reads poorly; every palette here is dark-ground, which is who picks one.
444 const BASE = paletteOf(options.palette).base
445
446 // The reply as it streams: every flush is whole lines but the last, which ends the message, so
447 // each line is complete when read and a URL is never taken half. Passed on: a settings hook
448 // beneath still runs.
449 on('classic.MessageDisplay', async ($, e, next) => {
450 const found: Found[] = [
451 ...linksIn(e.delta, 'reply', openWords).map(link),
452 ...replyPaths(e.delta, openWords),
453 ]
454 if (found.length) await collect($, found)
455 return next(e)
456 })
457
458 on('session.start', async ($, e, next) => {
459 const started = await next(e)
460 await $.command.register({ name: 'files', description: 'Open a file, link or folder this session produced' })
461 await rebuild($, openWords) // a resumed or re-identified conversation: the count is right before /files
462 await publish($)
463 // A file deleted outside Claude (Explorer, another shell) fires no hook: recount on a clock.
464 $.clock.every(30_000, () => void publish($))
465 return started
466 })
467
468 on('command.run', { command: 'files' }, async $ => {
469 await openPane($, openWords)
470 return {} // silent: the pane itself is the answer
471 })
472
473 // A Write always names the file it wrote, absolute: taken as is, spaces and all.
474 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
475 const ran = await next(e)
476 const path = e.file_path?.replace(/\\/g, '/')
477 const ok = ran.deny === undefined && ran.isError !== true && path && IS_OPENABLE.test(path)
478 await collect($, ok ? [{ kind: 'file', target: path }] : [])
479 return ran
480 })
481
482 // An Edit names the file it changed the same way (Julian, 2026-10-05: a doc edited is one to open).
483 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
484 const ran = await next(e)
485 const path = e.file_path?.replace(/\\/g, '/')
486 const ok = ran.deny === undefined && ran.isError !== true && path && IS_OPENABLE.test(path)
487 await collect($, ok ? [{ kind: 'file', target: path }] : [])
488 return ran
489 })
490
491 // Everything else, one hook with no matcher (a matcher over a list of names sent tsc into
492 // TS2589): read the answer, keep what is openable AND on disk. Bash is here because the
493 // scripts that produce the real artefacts — invoices, reports — are run, not written; it also
494 // yields dev-server addresses from its output (every URL when it is a `gh … create`, whose
495 // output is the thing made) and the folders its command made. A screenshot tool names the
496 // file it saved. A published artifact and a deploy tool of any MCP server name a URL.
497 on('tool.call', async ($, e, next) => {
498 const since = await $.clock.now()
499 const ran = await next(e)
500 if (ran.deny !== undefined || ran.isError === true) return ran
501 const producesFiles = e.tool === 'Bash' || SCREENSHOT_TOOLS.includes(e.tool)
502 const producesLinks = e.tool === 'Artifact' || DEPLOY_TOOL.test(e.tool)
503 if (!producesFiles && !producesLinks) return ran
504 const found: Found[] = []
505 const roots: string[] = []
506 if (producesFiles) {
507 roots.push(await $.session.root().catch(() => ''), await $.session.cwd().catch(() => ''))
508 found.push(...pathsIn(ran).map(target => ({ kind: 'file' as const, target })))
509 }
510 if (e.tool === 'Bash') {
511 const cmd = typeof e.command === 'string' ? e.command : ''
512 found.push(...linksIn(textOf(ran), GH_CREATE.test(cmd) ? 'tool' : 'servers').map(link))
513 found.push(...foldersIn(cmd, 'command').map(target => ({ kind: 'folder' as const, target })))
514 }
515 if (producesLinks) found.push(...linksIn(textOf(ran), 'tool').map(link))
516 await collect($, found, since, roots.filter(Boolean))
517 return ran
518 })
519
520 // No band of its own: the hook only measures the width for the status line, then lets the
521 // engine draw whatever belongs there.
522 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
523 const width = e.viewport?.columns ?? 0
524 if (width && width !== columns) {
525 columns = width
526 void publish($)
527 }
528 return next(e)
529 })
530
531 // The arrows and Tab walk every Button, the h/j/k/l ones too; the ring stays on the rows, so
532 // an arrow past the page's edge stops there (j/k turn the page instead). Tracks the row too.
533 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
534 if (isNav(e.element)) return {}
535 const row = /^row:(\d+)$/.exec(e.element ?? '')
536 if (row) focused = Number(row[1])
537 return next(e)
538 })
539
540 // Esc while the filter is up returns to the list; the pane stays. The engine's Esc reaches us
541 // as a close (closeOnEscape): answered without `next`, it is refused and the list is drawn.
542 on('ui.close', { id: PANE }, async ($, e, next) => {
543 if (filtering && e.origin.kind === 'person') {
544 filtering = false
545 query = ''
546 page = 0
547 focused = 0
548 $.ui.invalidate('ui.render')
549 // Esc has already handed the keyboard back to the prompt before the close reaches us
550 // (Julian 2026-10-04: the list stayed, the focus went to the prompt). Ask for it again and
551 // put the ring on row 1; not awaited, the close is still being answered.
552 void $.ui
553 .open({ id: PANE, title: 'Files', focus: true, closeOnEscape: true, holdToasts: true })
554 .then(() => $.ui.focus({ requestId: PANE, key: 'row:0' }))
555 .catch(() => null)
556 return { value: undefined } // answered here: the pane stays
557 }
558 return next(e)
559 })
560
561 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
562 const { Box, Text, Button } = $.ui.resolve(e)
563 // The pane paints its own ground (Julian 2026-10-04): under a `*-ansi` Claude Code theme the
564 // dialog takes an ANSI grey from the terminal's scheme, and neither the rows nor the engine's
565 // blue hotkeys read on it. The palette's base, filling the body; text keeps the terminal's
566 // foreground. Docked the body is the screen; inline it is what the layout spares.
567 const ground = { flexDirection: 'column' as const, backgroundColor: BASE, minHeight: e.props?.scroll?.bodyRows }
568 const all = await stored($)
569
570 // Only what is still on disk: a file can be deleted or moved after it was collected, and a
571 // button that opens nothing reads as the mod being broken rather than as a missing file.
572 // A link is never checked: there is no disk to ask.
573 const checked = await Promise.all(
574 all.map(async entry =>
575 entry.kind === 'link' || (await $.fs.exists(entry.target).catch(() => false)) ? entry : null,
576 ),
577 )
578 const list = checked.filter((entry): entry is Entry => entry !== null)
579
580 // The newest entry doubles as the empty check: `noUncheckedIndexedAccess` types it undefined.
581 const newest = list[list.length - 1]
582 if (!newest) {
583 return (
584 <Box {...ground}>
585 <Text>Nothing to open yet this session.</Text>
586 </Box>
587 )
588 }
589 // A page is as long as the body the surface granted, less the hint rows. Docked (fullscreen
590 // from 110 columns) that is floor to ceiling; inline above the prompt it is what the layout
591 // spares, which a wrapped prompt shortens. A page taller than the body made the engine scroll
592 // it: the hint fell off and the arrows scrolled instead of walking.
593 const body = e.props?.scroll?.bodyRows ?? Infinity
594 // The Input takes a row off the page while it is up.
595 // The pages are cut from `pool`: the list newest first, or the matches best first.
596 const pool = filtering && query ? fuzzy(query, list) : list.slice().reverse()
597 // Two hint rows only when they cost no entry; tight (inline under a long transcript) the hint is
598 // one row of keys, o and f first, the walk after them, no prose (Julian 2026-10-04: the walk is
599 // learned once, o and f are rarer). Hidden Buttons would lose their hotkeys, so the keys stay.
600 const room = body - (filtering ? 1 : 0)
601 const hintRows = room - HINT_ROWS >= Math.min(PER_PAGE, Math.max(1, pool.length)) ? HINT_ROWS : 1
602 const perPage = Math.max(1, Math.min(PER_PAGE, room - hintRows))
603 const pages = Math.max(1, Math.ceil(pool.length / perPage))
604 page = Math.min(page, pages - 1) // a file deleted since can shorten the list under us
605 const shown = pool.slice(page * perPage, (page + 1) * perPage)
606 const last = shown.length - 1
607 focused = Math.min(focused, Math.max(0, last)) // a shorter page than the one the ring was on
608 const lastRow = (p: number) => Math.min(perPage, pool.length - p * perPage) - 1
609
610 const startFilter = () => {
611 filtering = true
612 query = ''
613 page = 0
614 focused = 0
615 $.ui.invalidate('ui.render')
616 void $.ui.focus({ requestId: PANE, key: 'q' }).catch(() => null)
617 }
618
619 // j past the last row turns to the next page's first; k before the first, to the previous
620 // page's last. The list is a ring (Julian, 2026-10-04): past either end it comes round, j
621 // from the last entry to page 1's first row, k from the first to the last page's last.
622 const down = () => (focused < last ? go($, page, focused + 1) : go($, (page + 1) % pages, 0))
623 const up = () => {
624 const p = focused > 0 ? page : (page - 1 + pages) % pages
625 return go($, p, focused > 0 ? focused - 1 : lastRow(p))
626 }
627 const turn = (to: number) => () => go($, (to + pages) % pages, 0)
628
629 // A thing that opened takes the person to another app; the pane closes behind it, so they do
630 // not come back to a dialog they have to dismiss. A failed open keeps it to pick again.
631 const open = (entry: Entry) => () =>
632 openerFor($, entry.target)
633 .then(cmd => $.process.run(cmd))
634 .then(r =>
635 r.exitCode === 0
636 ? $.ui.close({ id: PANE })
637 : $.ui.toast(`could not open ${entry.label} (exit ${r.exitCode})`),
638 )
639 .catch(() => $.ui.toast(`could not open ${entry.label}`))
640 // `o`: the folder of the focused row — a file's parent, a folder itself; a link has none.
641 const folderOfRow = () => {
642 const entry = shown[focused]
643 if (!entry) return
644 if (entry.kind === 'link') return $.ui.toast('Links have no folder')
645 const target = entry.kind === 'folder' ? entry.target : parentOf(entry.target)
646 return open({ ...entry, target, label: target })()
647 }
648
649 // The field, while filtering. Its text is drawn back on every change, so a redraw (a new
650 // entry arriving) does not empty it. Enter opens the best match; nothing when there is none.
651 // Resolved under the surfaces that have an `Input` (mobile does not), as `Image` is below.
652 let header = null
653 if (filtering && (e.surface === 'terminal' || e.surface === 'desktop' || e.surface === 'vscode')) {
654 const { Input } = $.ui.resolve(e)
655 header = (
656 <Input
657 key="q"
658 label="find"
659 value={query}
660 // Short: the hint row says the keys. A long placeholder pushed the Enter label onto a row
661 // of its own, and the engine's default label `submit` named nothing this field does.
662 placeholder="type to filter"
663 submitLabel="open"
664 autoFocus
665 onInput={(value: string) => {
666 query = value
667 page = 0
668 focused = 0
669 $.ui.invalidate('ui.render')
670 }}
671 onSubmit={() => (pool[0] ? open(pool[0])() : undefined)}
672 />
673 )
674 }
675
676 let inline = null
677 // The newest file only, on page 1 only, and not while filtering (the list needs the rows):
678 // it is what `openPane` asked rows for. Left out when the body cannot hold it under the rows
679 // and the hint: a row is worth more than a preview.
680 const roomForImage = body >= shown.length + hintRows + IMAGE_ROWS + 1
681 if (page === 0 && !filtering && e.surface === 'terminal' && isPng(newest.target) && roomForImage) {
682 // Resolved here, not above: `Image` exists only in the terminal's element table, and
683 // narrowing on e.surface is what hands it over. A module has no element globals.
684 const { Image } = $.ui.resolve(e)
685 const stat = await $.fs.stat(newest.target).catch(() => null)
686 if (stat && stat.size <= INLINE_MAX_BYTES) {
687 const file = await $.fs.read(newest.target, { as: 'bytes' }).catch(() => null)
688 if (file) {
689 // A fixed, small box: it is what `openPane` asks rows for, and a terminal cell is about
690 // twice as tall as wide, so 3 columns a row keeps a screenshot roughly in proportion.
691 const cols = Math.min(IMAGE_ROWS * 3, Math.max(10, (e.props?.bodyColumns ?? 60) - 2))
692 inline = (
693 <Image
694 source={{ png: file.base64 }} // FsBytes is `{ base64 }`, already encoded
695 columns={cols}
696 rows={IMAGE_ROWS}
697 // The engine draws pixels in kitty and Ghostty only; everywhere else, this.
698 alt="(image preview needs kitty or Ghostty)"
699 />
700 )
701 }
702 }
703 }
704
705 if (!pool.length) {
706 // A filter that matches nothing: the field stays, so the person can retype or Esc back.
707 return (
708 <Box {...ground}>
709 {header}
710 <Text>{`No match for ‹${query}›.`}</Text>
711 </Box>
712 )
713 }
714
715 return (
716 <Box {...ground}>
717 {header}
718 {shown.map((entry, i) => (
719 // `autoFocus?: true` is a literal-true type, so `false` is not "off", it is an
720 // invalid prop — and one invalid prop refuses the WHOLE tree, not just that element.
721 // Keyed by position: `row:N` is what `go` focuses after a page turn. The ring starts on
722 // the row the pane holds (`go` focuses the same one), so a redraw keeps it; while the
723 // field is up, the field keeps it. The muted tail says where the thing is, which tells
724 // two files of one name apart.
725 <Box key={`line:${i}`} flexDirection="row" columnGap={1}>
726 <Button
727 key={`row:${i}`}
728 plain
729 hotkey={String(i + 1)}
730 autoFocus={!filtering && i === focused ? true : undefined}
731 onPress={open(entry)}
732 >
733 {`${MARK[entry.kind]} ${entry.label}`}
734 </Button>
735 {/* The terminal's own foreground on the palette's base (`ground`); italic sets the tail apart. */}
736 <Text italic wrap="truncate-end">
737 {entry.tail}
738 </Text>
739 </Box>
740 ))}
741 {/* The hint, always drawn, two rows (HINT_ROWS) or one when tight: its keys are live Buttons (`j: ↓`), so it cannot
742 drift from what works. `openPane` counts these rows. */}
743 {/* A short last page keeps the page's height (Julian 2026-10-04): the hint stays where it was,
744 so a turn from 8 rows to 7 reads as a turn, not as an entry gone. Only with a second page. */}
745 {pages > 1
746 ? Array.from({ length: perPage - shown.length }, (_, i) => <Text key={`pad:${i}`}> </Text>)
747 : null}
748 {(() => {
749 const walk = [
750 <Button key={NAV.j} plain dimColor hotkey="j" onPress={down}>
751 ↓
752 </Button>,
753 <Button key={NAV.k} plain dimColor hotkey="k" onPress={up}>
754 ↑
755 </Button>,
756 ...(pages > 1
757 ? [
758 <Button key={NAV.h} plain dimColor hotkey="h" onPress={turn(page - 1)}>
759 ‹
760 </Button>,
761 // Which entries, not only which page: a wrap from page 1 to a shorter last page read
762 // as an entry lost (Julian 2026-10-04, 15 entries: 8 then 7).
763 <Box key="range" flexShrink={0}>
764 <Text bold>{`${page * perPage + 1}–${page * perPage + shown.length} of ${pool.length} · ${page + 1}/${pages}`}</Text>
765 </Box>,
766 <Button key={NAV.l} plain dimColor hotkey="l" onPress={turn(page + 1)}>
767 ›
768 </Button>,
769 ]
770 : []),
771 ]
772 // While filtering, o and f are bare keys (Julian 2026-10-05: the field explains itself); f is
773 // the way back from the matches to the field, text kept (only Shift+Tab came back before,
774 // and a missing f fell through to the prompt).
775 const acts = [
776 <Button key={NAV.o} plain dimColor hotkey="o" onPress={folderOfRow}>
777 {filtering ? '' : 'folder'}
778 </Button>,
779 <Button
780 key={NAV.f}
781 plain
782 dimColor
783 hotkey="f"
784 onPress={filtering ? () => void $.ui.focus({ requestId: PANE, key: 'q' }).catch(() => null) : startFilter}
785 >
786 {filtering ? '' : 'find'}
787 </Button>,
788 ]
789 // Filtering, the row leads with the way into the matches, then how to walk them.
790 const tab = filtering
791 ? [
792 <Box key="tab" flexShrink={0}>
793 <Text>Tab: matches</Text>
794 </Box>,
795 ]
796 : []
797 return hintRows === HINT_ROWS ? (
798 <>
799 <Box flexDirection="row" columnGap={1}>
800 {tab}
801 {walk}
802 </Box>
803 <Box flexDirection="row" columnGap={1}>
804 {acts}
805 <Text wrap="truncate-end">
806 {filtering ? '· Enter opens the top match · Esc back' : '· Enter or 1–8 opens · Esc closes'}
807 </Text>
808 </Box>
809 </>
810 ) : (
811 <Box flexDirection="row" columnGap={1}>
812 {/* Short, and cut at the edge rather than wrapped: this row has no second line to spill into. */}
813 {filtering ? [...tab, ...walk, ...acts] : [...acts, ...walk]}
814 <Text wrap="truncate-end">{filtering ? '· Esc back' : '· Enter opens · Esc closes'}</Text>
815 </Box>
816 )
817 })()}
818 {inline}
819 </Box>
820 )
821 })
822}
823palettes.js 61 lines1// One palette table for the mod (runs in the engine) and the status line (runs in Node).
2// Seven roles, named by what they mean on the row, not by hue. A value is `#rrggbb`, or one of
3// the ANSI color names below so the `ansi` palette works on any terminal without truecolor.
4//
5// Sources, read 2026-10-03:
6// rose-pine https://github.com/rose-pine/palette (palette.json)
7// catppuccin-mocha https://github.com/catppuccin/palette (palette.json, flavor mocha)
8// tokyo-night https://github.com/folke/tokyonight.nvim (extras/lua/tokyonight_night.lua)
9
10export const ROLES = ['muted', 'subtle', 'gold', 'love', 'foam', 'iris', 'track', 'base']
11
12export const PALETTES = {
13 'rose-pine': {
14 muted: '#6e6a86', // separators, secondary counts
15 subtle: '#908caa', // the readable default
16 gold: '#f6c177', // uncommitted work, warnings
17 love: '#eb6f92', // detached head, context nearly full, alerts
18 foam: '#9ccfd8', // room to spare, lines added
19 iris: '#c4a7e7', // accent
20 track: '#403d52', // unlit bar cells (highlight-med)
21 base: '#191724', // the Files pane's ground (base)
22 },
23 'catppuccin-mocha': {
24 muted: '#6c7086', // overlay0
25 subtle: '#a6adc8', // subtext0
26 gold: '#f9e2af', // yellow
27 love: '#f38ba8', // red
28 foam: '#94e2d5', // teal
29 iris: '#cba6f7', // mauve
30 track: '#45475a', // surface1
31 base: '#1e1e2e', // base
32 },
33 'tokyo-night': {
34 muted: '#565f89', // comment
35 subtle: '#a9b1d6', // fg_dark
36 gold: '#e0af68', // yellow
37 love: '#f7768e', // red
38 foam: '#1abc9c', // teal
39 iris: '#bb9af7', // magenta
40 track: '#3b4261', // fg_gutter
41 base: '#1a1b26', // bg
42 },
43 // The terminal's own 16 colors: whatever theme the user runs, these follow it.
44 ansi: {
45 muted: 'gray',
46 subtle: 'white',
47 gold: 'yellow',
48 love: 'red',
49 foam: 'cyan',
50 iris: 'magenta',
51 track: 'gray',
52 base: 'black',
53 },
54}
55
56export const DEFAULT_PALETTE = 'rose-pine'
57
58/** The palette for a settings value; anything unknown is the default, never a crash. */
59export const paletteOf = name =>
60 typeof name === 'string' && Object.hasOwn(PALETTES, name) ? PALETTES[name] : PALETTES[DEFAULT_PALETTE]
61types/index.d.ts 12 lines1export type EntryKind = 'file' | 'link' | 'folder'
2/** One row of the pane: what this session produced and a person may open. */
3export type Entry = { kind: EntryKind; target: string; label: string; tail: string; at: number }
4/** The shape stored before v0.2.0; read as a file entry. */
5export type Doc = { path: string; label: string; at: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 cockpit: { files: (Entry | Doc)[] }
10 }
11}
12