SLOPSHOPPER

cockpit

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.

newpanebandguardcommandtoast
v0.2.7MITupdated 2026-10-05LolerHero/claude-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to open yet this session. │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /files │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
Nothing to open yet this session.
README

claude-cockpit

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

A Claude Code session in WezTerm on Windows: Claude writes two files, a folder and a link and the status-line count climbs; ctrl+x f opens the pane, j and k walk the rows, and the find field filters with Tab into the matches and f back to the field

What you get

The status line, one row:

Opus 5.5 │ Coding │ main* +12 -3 │ ▪▪▪▪▪                  3  ·  1h 20m

The status line while Claude works: model, directory, branch with churn and the context bar; the count beside the file icon climbs as the reply writes files and names a folder and a link

  • 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.

ctrl+x f opens the pane docked beside the session; j and k walk the rows, each marked as a link, file or folder with its location in the muted tail

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:

The same session in a narrow window: the pane opens inline above the prompt, one entry a page, and the hint is a single row of keys

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.

The find field: f opens it and typing "lau" narrows the list; Tab moves into the matches and j walks them; f returns to the field, the text is changed to "rep", and Esc goes back to the full list

What counts:

  • A file when a person would open it: documents, images, spreadsheets, slides, archives, Markdown and HTML, written by a tool or produced by a command. Source code never counts, since that belongs in your editor. Also an absolute path to one alone on a reply line, when the file exists (Claude listing files it wants you to see). A 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.
  • A link from a tool: an artifact publish, a 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.
  • A folder this session made: 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.

Requirements

  • Claude Code 2.1.289 or later. Built and tested against 2.1.289. The plugin hooks API is young and may move.
  • Node 22 or later.
  • A Nerd Font for the file and clock icons, or set glyphs to plain.
  • wezterm is optional. With it the status line reads the exact pane width. Without it, it uses the width the mod measured, and 80 columns if that is missing.

Install

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.

Palettes

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).

How it works

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.

What the mod does on your machine

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.

  • Writes one file: ~/.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.
  • Reads four environment variables, all paths or the OS name, never a token or key: CLAUDE_CONFIG_DIR, USERPROFILE and HOME to find the config folder for that file, and OS to tell Windows apart when opening something.
  • Reads this session's conversation, and changes none of it: the hooks on Claude's replies as they stream (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.
  • Runs programs only to open what you pick, plus one check:
  • opening a row (Enter, its digit, or 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.
  • before that, on macOS and Linux only, uname once, to tell macOS (open) from Linux (xdg-open).
  • Adds one command: /files, which opens the pane. The pane's own keys work only while it is open.

Check it

claude plugin validate .
claude plugin test .
node --test "test/*.test.mjs"

License

MIT

Source 3 files
hooks/register.tsx 823 lines
1// 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}
823
palettes.js 61 lines
1// 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]
61
types/index.d.ts 12 lines
1export 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