Claudashboard: a clean hub pane for Claude Code. Session vitals (model, effort, context, cost, rate limits), live subagent cards, and a mouse + keyboard file…

A clean hub pane for Claude Code. It shows the session's vitals, the subagents (running and finished), and a file explorer you drive with the mouse or the keyboard, with a syntax-highlighted preview and a small editor inside the pane.

Every number comes from real session events. Nothing leaves your machine.
You need Claude Code 2.1.287 or later, which is when mods arrived.
Inside Claude Code:
/plugin marketplace add strackzdev/Claudashboard
/plugin install claudashboard@claudashboard
/claudashboard
git clone https://github.com/strackzdev/Claudashboard
claude plugin marketplace add ./Claudashboard
claude plugin install claudashboard@claudashboard
To try it for a single session without installing it:
claude --plugin-dir ./Claudashboard
| Shows | Notes |
|---|---|
| Model | The main loop's model. 1M means the long-context variant. It's read when the session starts and updated on every step. |
| Effort | The current turn's reasoning effort, from low to max. |
| Permission mode | default, auto, plan, acceptEdits or bypassPermissions, each in its own colour. Updated on each prompt, tool call and turn end (Claude Code raises no event for Shift+Tab itself). |
| Turn | ● and a live timer while Claude works. ○ idle and the last turn's length when it's done. |
| Context | A gauge plus used / window tokens. ⟲n counts compactions. From 80% the box's frame and gauge turn amber with ⚠ filling up; from 90% red with ⚠ compact soon. A toast marks each threshold once, and a line under the prompt keeps the percentage in view with the pane closed. |
| Cost | The session total, plus what the last turn added. |
| Limits | The 5-hour and 7-day rate-limit windows, when your plan reports them. |
| Turns | Off by default; /claudashboard turns shows it (remembered across sessions). 12 turns cost ▁▃█▂ $0.21 (avg $0.11) time ▂▅▁▃ 0:20 (avg 0:14): one bar per turn, oldest left and latest right (up to 16), each followed by the latest turn's value and the average. |
Below the session box, two tabs sit on the top right edge of the box: FILES and AGENTS. Click a tab, or press Tab while the pane has the keyboard (in edit mode and while typing a filter, Tab types; click the tab instead). /claudashboard agents and /claudashboard files switch from the prompt. While subagents run, the agents tab shows how many: ⠹ AGENTS (2), its spinner turning while they work. On the files tab inside a git repository, the project bar sits left of the tabs: ⎇ claudashboard on main ↑2 ↓1 3 changed · 1 staged (or ✓ clean). The agents tab leaves it out.
Press ? on either tab for every key, the current view's first. Arrows scroll the help; any other key closes it. The hint line under each view only shows the main keys.

Running: each agent gets a card showing:
↳)⟶), such as Read middleware.ts or $ npm testFinished: when an agent ends, it moves to the history below, newest first, on one line: name, type, model, steps, tools, context, task and how long it ran. The last 20 are kept. c clears them. ↑ ↓ and PgUp PgDn scroll the tab.
The explorer starts at the session's working directory.
Mouse: click a folder to open it, or a file to preview it. Click the breadcrumb to go back. Rows highlight as you hover over them.
Keyboard: click the list once to give it the keyboard. Esc hands the keyboard back to the prompt.
| Key | In the list | In the preview |
|---|---|---|
↑ ↓ / k j | move | scroll |
PgUp PgDn | page | page (Space too) |
Home End | first / last | top / end |
g G | — | top / end |
⏎ → l | open | — |
⌫ ← h | parent folder (clears a filter first) | back to the list (q too) |
/ | filter by name as you type (⏎ or Tab to finish) | — |
f / ctrl+p | find a file anywhere (see below) | find a file anywhere |
. | show / hide dotfiles | — |
m | — | Markdown: rendered ↔ source |
t | only touched files: in a git repository, what git status reports changed; elsewhere, what Claude edited this session | — |
r | refresh | refresh the git marks |
d | — | diff against the last commit (d or ⌫ back to the file) |
| click, shift+click, drag | — | pick lines; @ then mentions just them (@src/a.ts#10-20) |
@ | insert @path into your prompt | insert @path (with the picked lines, if any) into your prompt |
e | open the file straight into edit mode | switch to edit mode |
Tab | agents tab | agents tab |
? | help | help |
f (or ctrl+p) opens a fuzzy finder over every file under the working directory: in a git repository, what git tracks plus untracked files that aren't ignored; elsewhere, a walk of the folders that skips node_modules, .git, build outputs and (unless dotfiles show) dotfolders, up to 20,000 files. Type letters of the path in order (midw finds src/auth/middleware.ts). A match in the file's own name ranks first, then letters in a row and the starts of words. The matched letters are highlighted.
↑ ↓ (or ctrl+n / ctrl+p) pick, ⏎ or a click opens the file with the list on its folder, ctrl+u clears the query, and ⌫ on an empty query closes the finder.
A label at the top right of the file box shows the mode: VIEW MODE (blue), EDIT MODE (yellow, and the box border turns yellow too) or READ ONLY. The shortcut is shown right beside it (e to edit / ctrl+e to view). Click the label to switch modes, or press e.
| Key, in edit mode | Does |
|---|---|
typing, Space, Tab | insert text. Tab inserts a tab if the file indents with tabs, otherwise two spaces. Pasted text goes in whole, newlines included. |
⏎ | new line, keeping the current line's indentation |
⌫ Del | delete before / after the cursor, joining lines at the edges |
↑ ↓ ← → | move. Left and right wrap between lines. |
Home End | start of the line's indentation (press again for column 1) / end of line |
PgUp PgDn, ctrl+Home ctrl+End | page / start and end of the file |
| click | put the cursor there |
ctrl+s | save (or run /claudashboard save) |
ctrl+z | undo, up to 100 steps per file |
ctrl+e | back to view mode. With unsaved changes, the first press warns and a second discards them. |
● modified shows next to the label until you save, and the counter shows Ln, Col. While you're editing, Esc hands the keyboard back to the prompt and keeps your edits. Click the file box to carry on.
Safety:
ctrl+s warns first and only a second ctrl+s overwrites it. Without unsaved edits, the open file simply reloads when Claude changes it..md, .markdown, .mdx) open rendered, with headings, lists, emphasis, code blocks, tables and links. Press m to switch to the source, which has the git gutter, and m again to go back. e edits the source like any other file, and leaving the editor shows it rendered again.sudo apt install imagemagick). Without either, the pane tells you what to install.Every line of an open file has a gutter mark, in view mode and edit mode, layered the way git sees the file:
| Mark | Means |
|---|---|
yellow ▎ | edited here, not saved yet |
green ▎ | added since the index (not staged) |
blue ▎ | modified since the index (not staged) |
red ▁ | lines deleted just below this one |
purple ▎ | staged (git added), not yet committed |
The footer adds them up, for example ● 2 unsaved +3 ~2 −1 ✓4 staged. In the file list, each file shows its git status: M modified, S or A staged, U untracked, D deleted, ! conflict. Folders holding changes get a •.
While the pane is open, it refreshes every 3 seconds: files you edit, create or delete in another editor, and git add / commit run in a terminal, show up on their own. An open file reloads when it changes on disk, or warns you if you have unsaved edits.
Outside a git repository, the gutter compares the file with how it was when you first opened it, so lines you changed and saved still show (since opened). The marks refresh after each save, when you leave the file, after each Claude turn, and when you press r in the preview.
While a turn runs, a file Claude (or a subagent) is reading shows ◉ in purple and a file it's editing ✎ in yellow, bold, and so does every folder above it; the marks clear when the turn ends.
Touched files are marked ✎. In a git repository that means what git status reports changed right now, whoever changed it: a file you revert or commit is no longer touched. Outside git, it means the files Claude edited or created this session. New files appear automatically. The preview won't show binary files or files over 1 MB, and it shows at most the first 5,000 lines.
| Command | Does | |
|---|---|---|
/claudashboard | open the hub | |
/claudashboard close | close it | |
/claudashboard files, /claudashboard agents | show that tab | |
/claudashboard save | save the file being edited | |
/claudashboard cd <path> | jump the explorer to a folder: relative to the working directory, or an absolute path inside it (.. is refused) | |
/claudashboard reset | clear Claude's touched files (outside git) and the agents, running and finished, and move the explorer back to the root | |
| `/claudashboard turns [on\ | off]` | show or hide the session box's turns row (cost and length of the last turns); remembered across sessions |
/cdb is the short form: /cdb, /cdb cd src, /cdb turns and so on.
Open /config and find claudashboard, or set the values under pluginConfigs.claudashboard in your settings.
| Option | Default | |
|---|---|---|
palette | onedark | onedark uses fixed One Dark colours. theme follows your Claude Code theme. |
showHidden | false | Show dotfiles in the explorer by default. |
openOnStart | true | Open the pane when a session starts. |
strackz › … › CODEOWNERS) and then shortens the name, the mode label switches to VIEW / EDIT / RO, and the line count sits at the bottom right./claudashboard close hides Claudashboard until you run /claudashboard again.claude plugin validate . # manifest, marketplace and hooks, as the engine reads them
claude plugin test . # unit and drawing tests
claude --plugin-dir . # a session with your working copy loaded, hot-reloading on save
Start with AGENTS.md: the commands, the rules the engine enforces, and links to the architecture, the conventions and the decision records.
MIT © strackzdev
hooks/register.tsx 433 lines1// Claudashboard: session vitals, running subagents, and a file explorer with an in-pane preview.
2// This module wires the engine's events to the modules that do the work.
3import type { EngineInterface, Register } from 'claude-code'
4import type { Agent, Entry } from '../types'
5import { contextLevel, pushTurn, usageToVitals } from './session/vitals'
6import { describeTool, finishAgent, keepAgents, normalizeAgent, stepAgent } from './agents/agents'
7import { agentLines, reduceAgentsKey } from './agents/view'
8import { helpLines, reduceHelp } from './help'
9import { findFiles, reduceFinder } from './explorer/finder'
10import { editMessage, enterEdit, leaveEdit, saveFile } from './explorer/editor/editing'
11import type { UiMessage } from './explorer/client'
12import { DEFAULT_EXPLORER, reduceKey, selectLine, visibleEntries } from './explorer/model'
13import { loadDir, loadPreview, noteEdit, openDiff, openFinder, openFound, poll } from './explorer/files'
14import { refreshGit } from './explorer/git/git'
15import { ONE_DARK, THEME, registerPane } from './pane'
16import { isAbsolute, parentOf, relTo, toSlash } from './shared/paths'
17import {
18 PANE,
19 getAgents,
20 getExplorer,
21 getView,
22 getVitals,
23 logged,
24 updateActivity,
25 openPane,
26 paneState,
27 setExplorer,
28 setPreview,
29 toKey,
30 updateAgents,
31 updateExplorer,
32 updateTouched,
33 updateView,
34 updateVitals,
35} from './state'
36
37const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
38
39async function goUp($: EngineInterface) {
40 const ex = await getExplorer($)
41 if (ex.preview?.mode === 'edit') return leaveEdit($)
42 if (ex.preview) {
43 await updateExplorer($, x => ({ ...x, preview: null }))
44 void refreshGit($).catch(logged($, 'refreshGit'))
45 return
46 }
47 if (ex.onlyTouched) {
48 await updateExplorer($, x => ({ ...x, onlyTouched: false }))
49 return loadDir($, ex.dir)
50 }
51 if (!ex.dir) return
52 return loadDir($, parentOf(ex.dir), ex.dir)
53}
54
55async function openEntry($: EngineInterface, entry: Entry, edit = false) {
56 if (entry.kind === 'dir') return loadDir($, entry.path)
57 await loadPreview($, entry.path)
58 if (edit) await enterEdit($)
59}
60
61async function mention($: EngineInterface, path: string) {
62 const filled = await $.prompt.fill({ text: `@${path} `, mode: 'insert' })
63 $.ui.toast(filled.isFilled ? `@${path} added to your prompt` : 'The prompt is busy; try again when it is free')
64}
65
66async function initExplorer($: EngineInterface, showHidden: boolean) {
67 const root = toSlash(await $.session.cwd())
68 const ex = await getExplorer($)
69 // Keep where the person was across reloads; start over when the session moved to another folder.
70 if (ex.root === root) {
71 await updateExplorer($, x => ({ ...x, showHidden }))
72 return loadDir($, ex.dir)
73 }
74 await setExplorer($, { ...DEFAULT_EXPLORER, root, showHidden })
75 return loadDir($, '')
76}
77
78/** The context level last announced: each threshold toasts once on the way up, and a compaction resets it. */
79let announced: 0 | 1 | 2 = 0
80
81/** A toast as the context crosses 80% and 90%, and a status line under the prompt while it stays there. */
82function alarmContext($: EngineInterface, pct: number | null) {
83 const level = contextLevel(pct)
84 const n = Math.round(pct ?? 0)
85 if (level > announced) $.ui.toast(level === 2 ? `Context ${n}% full: run /compact soon` : `Context ${n}% full`)
86 if (level > 0) $.ui.status(level === 2 ? `⚠ context ${n}%: compact soon` : `⚠ context ${n}%`)
87 else if (announced > 0) $.ui.status(undefined)
88 announced = level
89}
90
91async function refreshUsage($: EngineInterface) {
92 const u = await $.session.usage().catch(() => null)
93 if (!u) return
94 await updateVitals($, x => ({ ...x, ...usageToVitals(u) }))
95 alarmContext($, u.context?.percent ?? null)
96}
97
98/** The main loop's permission mode, from a classic hook's input; a subagent's events are left out. */
99async function noteMode($: EngineInterface, e: { permission_mode?: string; agent_id?: string }) {
100 const mode = e.permission_mode
101 if (!mode || e.agent_id) return
102 await updateVitals($, x => (x.mode === mode ? x : { ...x, mode })).catch(logged($, 'permission mode'))
103}
104
105/** Marks the file a Read or an edit tool is about to touch; an edit outranks a read for the rest of the turn. */
106async function noteActivity($: EngineInterface, e: { tool: string }) {
107 const kind = e.tool === 'Read' ? 'read' : EDIT_TOOLS.has(e.tool) ? 'edit' : null
108 if (!kind) return
109 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
110 const given = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
111 if (!given) return
112 const path = relTo((await getExplorer($)).root, toSlash(given))
113 await updateActivity($, a => (a[path] === 'edit' || a[path] === kind ? a : { ...a, [path]: kind }))
114}
115
116async function refreshModel($: EngineInterface) {
117 const model = await $.session.model().catch(() => '')
118 if (model) await updateVitals($, x => ({ ...x, model }))
119}
120
121/** Adds a card for a spawned subagent. */
122async function noteSpawn(
123 $: EngineInterface,
124 e: { name?: string; subagentType?: string; description?: string; background?: boolean },
125 id: string,
126 model: string | undefined,
127) {
128 const type = e.subagentType ?? 'agent'
129 const agent: Agent = normalizeAgent({
130 id,
131 name: e.name ?? type.split(':').pop() ?? 'agent',
132 type,
133 model: model ?? '',
134 task: e.description ?? '',
135 startedAt: await $.clock.now(),
136 isBackground: e.background === true,
137 })
138 await updateAgents($, list => keepAgents([...list.filter(a => a.id !== agent.id), agent]))
139}
140
141const COMMANDS = ['claudashboard', 'cdb'] as const
142
143export const register: Register = (on, options) => {
144 const C = options.palette === 'theme' ? THEME : ONE_DARK
145 const showHiddenDefault = options.showHidden === true
146
147 on('session.start', async ($, e, next) => {
148 // /cdb is the short form of /claudashboard.
149 for (const name of COMMANDS) {
150 await $.command.register({
151 name,
152 description: 'Claudashboard: open or close the hub, show a tab, jump the explorer to a folder, show the turns row, or reset it',
153 argumentHint: '[close|files|agents|cd <path>|save|reset|turns [on|off]]',
154 })
155 }
156 await Promise.all([refreshUsage($), refreshModel($)]).catch(logged($, 'session vitals'))
157 // The turns row is off unless the person turned it on, in this session or an earlier one.
158 const showTurns = (await $.store.get('showTurns').catch(() => undefined)) === true
159 await updateVitals($, v => ({ ...v, showTurns })).catch(logged($, 'showTurns'))
160 await initExplorer($, showHiddenDefault).catch(logged($, 'initExplorer'))
161 if (options.openOnStart !== false) void openPane($).catch(logged($, 'openPane'))
162 // Live refresh: edits made in another editor, git add/commit run elsewhere.
163 $.clock.every(3000, () => poll($).catch(logged($, 'poll')))
164 return next(e)
165 })
166
167 // The person closed the pane (its ✕) or the module unloads: stop asking git and the disk.
168 on('ui.close', { id: PANE }, async ($, e, next) => {
169 paneState.isOpen = false
170 return next(e)
171 })
172
173 on('session.end', async ($, e, next) => {
174 if (e.reason === 'clear') {
175 await updateAgents($, () => [])
176 await updateActivity($, () => ({}))
177 }
178 return next(e)
179 })
180
181 for (const command of COMMANDS)
182 on('command.run', { command }, async ($, e) => {
183 const args = e.args.trim()
184 const [verb = ''] = args.split(/\s+/)
185 if (verb === 'close') {
186 await $.ui.close({ id: PANE })
187 paneState.isOpen = false
188 return { text: 'Claudashboard closed.' }
189 }
190 if (verb === 'turns') {
191 // `turns` flips the row; `turns on` / `turns off` set it.
192 const arg = args.split(/\s+/)[1] ?? ''
193 const before = (await getVitals($)).showTurns
194 const showTurns = arg === 'on' ? true : arg === 'off' ? false : !before
195 await updateVitals($, v => ({ ...v, showTurns }))
196 await $.store.set('showTurns', showTurns).catch(logged($, 'showTurns'))
197 return { text: showTurns ? 'Claudashboard: turns row on (cost and length of the last turns).' : 'Claudashboard: turns row off.' }
198 }
199 if (verb === 'files' || verb === 'agents') {
200 await updateView($, v => ({ ...v, tab: verb, help: false }))
201 await openPane($)
202 return { text: `Claudashboard: ${verb} tab.` }
203 }
204 if (verb === 'reset') {
205 await updateTouched($, () => ({}))
206 await updateActivity($, () => ({}))
207 await updateAgents($, () => [])
208 await setExplorer($, DEFAULT_EXPLORER)
209 await initExplorer($, showHiddenDefault)
210 return { text: 'Claudashboard reset: explorer back at the root, touched files and agents cleared.' }
211 }
212 if (verb === 'save') {
213 const p = (await getExplorer($)).preview
214 if (!p || p.mode !== 'edit') return { text: 'Nothing to save: open a file and press e to edit it.' }
215 await saveFile($)
216 const after = (await getExplorer($)).preview
217 return { text: `Claudashboard: ${after?.status ?? 'saved'}` }
218 }
219 if (verb === 'cd') {
220 const ex = await getExplorer($)
221 const target = toSlash(args.slice(2).trim())
222 .replace(/^\.\/?/, '')
223 .replace(/\/+$/, '')
224 const root = ex.root.replace(/\/+$/, '')
225 const rel = target === root ? '' : isAbsolute(target) ? relTo(root, target) : target
226 if (isAbsolute(rel) || rel.split('/').includes('..')) return { text: `${target} is outside ${ex.root}.` }
227 await updateExplorer($, x => ({ ...x, onlyTouched: false, filter: '' }))
228 await loadDir($, rel)
229 const after = await getExplorer($)
230 if (after.error) return { text: `Claudashboard: ${after.error}` }
231 await openPane($)
232 return { text: `Claudashboard explorer at ./${rel}` }
233 }
234 await refreshModel($)
235 const opened = await openPane($)
236 if (!opened.isPlaced) return { text: `Claudashboard is not shown yet: ${opened.reason ?? 'no room'}` }
237 return { text: 'Claudashboard open. Click the file list to give it the keyboard; Esc hands it back.' }
238 })
239
240 // The engine raises nothing when the person switches mode (Shift+Tab), but every classic hook carries
241 // the mode: a prompt, each tool call, the end of a turn, and Claude waiting for the person.
242 on('classic.UserPromptSubmit', async ($, e, next) => {
243 await noteMode($, e)
244 return next(e)
245 })
246 on('classic.PostToolUse', async ($, e, next) => {
247 await noteMode($, e)
248 return next(e)
249 })
250 on('classic.Stop', async ($, e, next) => {
251 await noteMode($, e)
252 return next(e)
253 })
254 on('classic.Notification', async ($, e, next) => {
255 await noteMode($, e)
256 return next(e)
257 })
258
259 // turn.start is the main loop's alone: a subagent's turn never raises it.
260 on('turn.start', async ($, e, next) => {
261 const [now, u] = await Promise.all([$.clock.now(), $.session.usage().catch(() => null)])
262 await updateVitals($, x => ({ ...x, turnStartedAt: now, turnCostStart: u?.cost?.usd ?? null }))
263 return next(e)
264 })
265
266 on('turn.step', async function* ($, e, next) {
267 if (!e.agentId) {
268 await updateVitals($, v => ({ ...v, model: e.model, effort: e.effort === undefined ? v.effort : String(e.effort) }))
269 return yield* next(e)
270 }
271 const result = yield* next(e)
272 const id = e.agentId
273 await updateAgents($, list => list.map(a => (a.id === id ? stepAgent(a, e.model, result.usage) : a))).catch(logged($, 'agent step'))
274 return result
275 })
276
277 on('agent.spawn', async ($, e, next) => {
278 const started = await next(e)
279 if (started.deny !== undefined || !started.agentId) return started
280 // Bookkeeping only: a failure here must never fail the Agent call.
281 await noteSpawn($, e, started.agentId, started.model).catch(logged($, 'agent spawn'))
282 return started
283 })
284
285 on('turn.complete', async ($, e, next) => {
286 const done = await next(e)
287 if (e.agentId) {
288 // A finished agent moves to the history.
289 const id = e.agentId
290 const now = await $.clock.now()
291 await updateAgents($, list => finishAgent(list, id, now)).catch(logged($, 'agent done'))
292 return done
293 }
294 void refreshGit($).catch(logged($, 'refreshGit'))
295 await updateActivity($, () => ({})).catch(logged($, 'activity'))
296 const [now, u] = await Promise.all([$.clock.now(), $.session.usage().catch(() => null)])
297 await updateVitals($, v => {
298 const cost = u?.cost?.usd ?? null
299 const turnCost = cost !== null && v.turnCostStart !== null ? cost - v.turnCostStart : null
300 return {
301 ...v,
302 turnStartedAt: null,
303 lastTurnMs: v.turnStartedAt === null ? v.lastTurnMs : now - v.turnStartedAt,
304 lastTurnCost: turnCost ?? v.lastTurnCost,
305 costUsd: cost ?? v.costUsd,
306 turns: v.turnStartedAt === null ? v.turns : pushTurn(v.turns, { ms: now - v.turnStartedAt, cost: turnCost }),
307 }
308 })
309 return done
310 })
311
312 on('session.measure', async ($, e, next) => {
313 await updateVitals($, x => ({ ...x, ...usageToVitals(e) }))
314 alarmContext($, e.context?.percent ?? null)
315 return next(e)
316 })
317
318 on('session.compact', async ($, e, next) => {
319 const done = await next(e)
320 if (!e.agentId && e.trigger !== 'precompute') {
321 await updateVitals($, v => ({ ...v, compactions: v.compactions + 1 })).catch(logged($, 'compaction'))
322 }
323 return done
324 })
325
326 on('tool.call', async ($, e, next) => {
327 // An agent's current activity shows as the call starts, not once it ends.
328 if (e.agentId) {
329 const id = e.agentId
330 const what = describeTool(e.tool, e as unknown as Record<string, unknown>)
331 await updateAgents($, list => list.map(a => (a.id === id ? { ...a, tools: a.tools + 1, lastTool: what } : a))).catch(logged($, 'agent tool'))
332 }
333 // The file tree colours a file while Claude reads or edits it: set as the call starts.
334 await noteActivity($, e).catch(logged($, 'activity'))
335 const ran = await next(e)
336 if (!EDIT_TOOLS.has(e.tool) || ran.deny !== undefined || ran.isError === true) return ran
337 // Bookkeeping only: a failure here must never change the call's result.
338 await noteEdit($, e).catch(logged($, 'noteEdit'))
339 return ran
340 })
341
342 on('ui.message', async ($, e) => {
343 // The explorer client sends these; the checks below still guard each field.
344 const d = e.data as UiMessage
345 const room = Math.max(3, Number(d.room) || 10)
346 const ex = await getExplorer($)
347 const view = await getView($)
348 const k = d.k === 'key' && typeof d.key === 'string' ? toKey(d) : null
349 if (d.k === 'tab') {
350 await updateView($, v => ({ ...v, tab: d.tab === 'agents' ? 'agents' : 'files', help: false }))
351 return {}
352 }
353 // The help sits over either tab: arrows scroll it, any other key or a click on its title closes it.
354 if (view.help) {
355 if (k) await updateView($, v => reduceHelp(v, k, helpLines('list').length, room))
356 else if (d.k === 'crumb') await updateView($, v => ({ ...v, help: false, helpTop: 0 }))
357 return {}
358 }
359 // Tab switches tabs, except where it types: editing, or finishing a filter.
360 const isTyping = ex.preview?.mode === 'edit' || ex.isFiltering
361 if (k?.key === 'tab' && !k.ctrl && (view.tab === 'agents' || !isTyping)) {
362 await updateView($, v => ({ ...v, tab: v.tab === 'agents' ? 'files' : 'agents' }))
363 return {}
364 }
365 if (k?.key === '?' && (view.tab === 'agents' || (!isTyping && !ex.finder))) {
366 await updateView($, v => ({ ...v, help: true, helpTop: 0 }))
367 return {}
368 }
369 if (view.tab === 'agents') {
370 if (!k) return {}
371 const agents = await getAgents($)
372 const r = reduceAgentsKey(view, k, agentLines(agents).length, room)
373 if (r.view !== view) await updateView($, () => r.view)
374 if (r.effect?.kind === 'clear') await updateAgents($, list => list.filter(a => a.endedAt === null))
375 return {}
376 }
377 if (ex.finder) {
378 const f = ex.finder
379 if (d.k === 'crumb') await updateExplorer($, x => ({ ...x, finder: null }))
380 if (d.k === 'pick' && typeof d.index === 'number') {
381 const picked = findFiles(f.files, f.query)[d.index]
382 if (picked) await openFound($, picked.path)
383 }
384 if (!k) return {}
385 const r = reduceFinder(f, k, room)
386 if (r.finder !== f) await updateExplorer($, x => ({ ...x, finder: r.finder }))
387 if (r.effect?.kind === 'open') await openFound($, r.effect.path)
388 else if (r.effect?.kind === 'close') await updateExplorer($, x => ({ ...x, finder: null }))
389 return {}
390 }
391 if (ex.preview?.mode === 'edit') {
392 await editMessage($, ex.preview, d, room)
393 return {}
394 }
395 if (d.k === 'mode') {
396 if (ex.preview) await enterEdit($)
397 return {}
398 }
399 if (d.k === 'line' && typeof d.row === 'number' && ex.preview && !ex.preview.diff) {
400 const line = ex.preview.top + d.row
401 await setPreview($, p => selectLine(p, line, d.isExtend === true))
402 return {}
403 }
404 if (d.k === 'click' && typeof d.index === 'number') {
405 const entry = visibleEntries(ex)[d.index]
406 if (entry) {
407 await updateExplorer($, x => ({ ...x, cursor: d.index }))
408 await openEntry($, entry)
409 }
410 return {}
411 }
412 if (d.k === 'crumb') {
413 if (ex.isFiltering || ex.filter) await updateExplorer($, x => ({ ...x, isFiltering: false, filter: '', cursor: 0 }))
414 await goUp($)
415 return {}
416 }
417 if (d.k !== 'key' || typeof d.key !== 'string') return {}
418 const { ex: nextEx, effect } = reduceKey(ex, toKey(d), room, Math.max(20, Number(d.width) || 80))
419 if (nextEx !== ex) await setExplorer($, nextEx)
420 if (effect?.kind === 'open') await openEntry($, effect.entry, effect.edit === true)
421 else if (effect?.kind === 'edit') await enterEdit($)
422 else if (effect?.kind === 'up') await goUp($)
423 else if (effect?.kind === 'mention') await mention($, effect.path)
424 else if (effect?.kind === 'diff') await openDiff($)
425 // The listing can take a while in a large tree: the finder opens at once and fills in when it is done.
426 else if (effect?.kind === 'find') void openFinder($).catch(logged($, 'finder'))
427 else if (effect?.kind === 'reload') await (nextEx.preview ? refreshGit($) : loadDir($, nextEx.dir))
428 return {}
429 })
430
431 registerPane(on, C)
432}
433hooks/session/vitals.ts 62 lines1// Pure logic for Claudashboard: the session's vitals. No `$` here, so it is testable.
2import type { Turn, Vitals } from '../../types'
3
4export const DEFAULT_VITALS: Vitals = {
5 model: '',
6 effort: '',
7 mode: 'default',
8 pct: null,
9 tokens: null,
10 window: null,
11 costUsd: null,
12 limits: [],
13 compactions: 0,
14 turnStartedAt: null,
15 turnCostStart: null,
16 lastTurnMs: null,
17 lastTurnCost: null,
18 turns: [],
19 showTurns: false,
20}
21
22export type Usage = {
23 context?: { percent?: number | null; tokens?: number | null; window?: number | null } | null
24 cost?: { usd?: number | null } | null
25 rateLimits?: Array<{ kind: string; percentUsed: number }> | null
26}
27/** The vitals a usage report (from `session.usage()` or `session.measure`) sets. */
28export const usageToVitals = (u: Usage): Partial<Vitals> => ({
29 pct: u.context?.percent ?? null,
30 tokens: u.context?.tokens ?? null,
31 window: u.context?.window ?? null,
32 costUsd: u.cost?.usd ?? null,
33 limits: (u.rateLimits ?? []).map(r => ({ kind: r.kind, pct: r.percentUsed })),
34})
35
36/** How many finished turns the sparkline keeps. */
37export const MAX_TURNS = 16
38
39/** The turns with `t` added last, the oldest dropped past MAX_TURNS. */
40export const pushTurn = (turns: readonly Turn[], t: Turn): Turn[] => [...turns, t].slice(-MAX_TURNS)
41
42/** How full the context is: 0 fine, 1 from 80%, 2 from 90% (time to compact). */
43export function contextLevel(pct: number | null): 0 | 1 | 2 {
44 if (pct === null) return 0
45 return pct >= 90 ? 2 : pct >= 80 ? 1 : 0
46}
47
48/** What the turns row shows: each series oldest first (null costs as 0), the latest turn's values and the averages. */
49export function turnStats(turns: readonly Turn[]) {
50 const known = turns.flatMap(t => (t.cost === null ? [] : [t.cost]))
51 const last = turns[turns.length - 1]
52 return {
53 count: turns.length,
54 costs: known.length ? turns.map(t => t.cost ?? 0) : null,
55 lastCost: last?.cost ?? null,
56 avgCost: known.length ? known.reduce((n, c) => n + c, 0) / known.length : null,
57 times: turns.map(t => t.ms),
58 lastMs: last?.ms ?? 0,
59 avgMs: turns.reduce((n, t) => n + t.ms, 0) / Math.max(1, turns.length),
60 }
61}
62hooks/agents/agents.ts 71 lines1// Pure logic for Claudashboard: subagent cards and what their tool calls do. No `$` here, so it is testable.
2import type { Agent } from '../../types'
3import { baseName, toSlash } from '../shared/paths'
4
5/** One line on what a tool call does: `Read middleware.ts`, `Bash npm test`. */
6export function describeTool(tool: string, input: Record<string, unknown>): string {
7 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
8 const file = str('file_path') || str('notebook_path') || str('path')
9 if (file && /^(Read|Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return `${tool} ${baseName(toSlash(file))}`
10 if (tool === 'Bash') return `$ ${str('command').split('\n')[0]}`
11 if (tool === 'Grep' || tool === 'Glob') return `${tool} ${str('pattern')}`
12 if (tool === 'WebFetch') return `fetch ${str('url').replace(/^https?:\/\//, '')}`
13 if (tool === 'WebSearch') return `search ${str('query')}`
14 return tool
15}
16
17/** The model a step reports, applied to its agent: context is the step's whole input; output adds up. */
18export function stepAgent<A extends { steps: number; ctx: number; out: number; model: string }>(
19 a: A,
20 model: string,
21 usage: { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null | undefined,
22): A {
23 const u = usage ?? {}
24 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
25 return { ...a, model: a.model || model, steps: a.steps + 1, ctx: ctx > 0 ? ctx : a.ctx, out: a.out + (u.output_tokens ?? 0) }
26}
27
28/** A stored or partial agent with every field present, so a card from an older version still draws. */
29export function normalizeAgent(a: Partial<Agent> & { id: string }): Agent {
30 return {
31 name: 'agent',
32 type: 'agent',
33 model: '',
34 task: '',
35 startedAt: 0,
36 steps: 0,
37 ctx: 0,
38 out: 0,
39 tools: 0,
40 lastTool: '',
41 isBackground: false,
42 ...a,
43 endedAt: a.endedAt ?? null,
44 }
45}
46
47/** Finished agents kept in the history, and running ones kept (one whose end never came stays until it is pushed out). */
48export const MAX_FINISHED = 20
49export const MAX_RUNNING = 24
50
51/** The list to store: the most recent running and finished agents, in their order. */
52export function keepAgents(list: Agent[]): Agent[] {
53 const oldest = (xs: Agent[], keep: number) => xs.slice(0, Math.max(0, xs.length - keep))
54 const dropped = new Set([
55 ...oldest(
56 list.filter(a => a.endedAt === null),
57 MAX_RUNNING,
58 ),
59 ...oldest(
60 list.filter(a => a.endedAt !== null),
61 MAX_FINISHED,
62 ),
63 ])
64 return list.filter(a => !dropped.has(a))
65}
66
67/** The agent `id` finished at `now`: it moves to the history. */
68export function finishAgent(list: Agent[], id: string, now: number): Agent[] {
69 return keepAgents(list.map(a => (a.id === id && a.endedAt === null ? { ...a, endedAt: now, lastTool: '' } : a)))
70}
71hooks/agents/view.ts 53 lines1// Pure logic for Claudashboard: the agents tab's lines (running cards, then the finished history) and its keys. No `$` here, so it is testable.
2import type { Agent, View } from '../../types'
3import type { Key } from '../explorer/model'
4import { clamp, fmtTokens, prettyModel } from '../shared/format'
5
6export type AgentLine =
7 | { kind: 'title'; text: string }
8 | { kind: 'gap' }
9 | { kind: 'empty'; text: string }
10 /** A running agent's first line; its timer ticks on the client from `startedAt`. */
11 | { kind: 'run'; name: string; type: string; model: string; stats: string; startedAt: number; isBackground: boolean }
12 | { kind: 'task'; task: string; tool: string }
13 | { kind: 'done'; name: string; type: string; model: string; stats: string; ms: number; task: string }
14
15const statsOf = (a: Agent) => `${a.steps} steps · ${a.tools} tools${a.ctx ? ` · ${fmtTokens(a.ctx)} ctx` : ''}`
16
17/** The tab's lines: running agents (two lines each), then finished ones, latest first (one line each). */
18export function agentLines(list: Agent[]): AgentLine[] {
19 const running = list.filter(a => a.endedAt === null)
20 const finished = list.filter(a => a.endedAt !== null).reverse()
21 const lines: AgentLine[] = [{ kind: 'title', text: `Running · ${running.length}` }]
22 if (running.length === 0) lines.push({ kind: 'empty', text: 'No agent is running.' })
23 for (const a of running) {
24 const model = a.model ? prettyModel(a.model) : ''
25 lines.push({ kind: 'run', name: a.name, type: a.type, model, stats: statsOf(a), startedAt: a.startedAt, isBackground: a.isBackground })
26 lines.push({ kind: 'task', task: a.task, tool: a.lastTool })
27 }
28 lines.push({ kind: 'gap' }, { kind: 'title', text: `Finished · ${finished.length}` })
29 if (finished.length === 0) lines.push({ kind: 'empty', text: 'Agents show here once they finish.' })
30 for (const a of finished) {
31 const model = a.model ? prettyModel(a.model) : ''
32 lines.push({ kind: 'done', name: a.name, type: a.type, model, stats: statsOf(a), ms: (a.endedAt ?? a.startedAt) - a.startedAt, task: a.task })
33 }
34 return lines
35}
36
37export type AgentsEffect = { kind: 'clear' } | null
38
39/** One key on the agents tab: arrows and pages scroll, `c` clears the history. */
40export function reduceAgentsKey(v: View, k: Key, total: number, room: number): { view: View; effect: AgentsEffect } {
41 const last = Math.max(0, total - room)
42 const scroll = (top: number) => ({ view: { ...v, agentsTop: clamp(top, 0, last) }, effect: null })
43 const key = k.key
44 if (key === 'down' || key === 'j') return scroll(v.agentsTop + 1)
45 if (key === 'up' || key === 'k') return scroll(v.agentsTop - 1)
46 if (key === 'pagedown' || key === 'space') return scroll(v.agentsTop + Math.max(1, room - 1))
47 if (key === 'pageup') return scroll(v.agentsTop - Math.max(1, room - 1))
48 if (key === 'home' || key === 'g') return scroll(0)
49 if (key === 'end' || key === 'G') return scroll(last)
50 if (key === 'c') return { view: { ...v, agentsTop: 0 }, effect: { kind: 'clear' } }
51 return { view: v, effect: null }
52}
53hooks/help.ts 120 lines1// Pure logic for Claudashboard: the help overlay's keys and its scrolling. No `$` here, so it is testable.
2import type { View } from '../types'
3import type { Key } from './explorer/model'
4import { clamp } from './shared/format'
5
6export type HelpLine = { kind: 'title'; text: string } | { kind: 'key'; key: string; label: string } | { kind: 'gap' }
7
8/** Where the person pressed `?`: that part's keys come first. */
9export type HelpContext = 'list' | 'file' | 'diff' | 'agents'
10
11type Section = { id: HelpContext | 'everywhere' | 'edit' | 'finder'; title: string; keys: Array<[string, string]> }
12
13const SECTIONS: Section[] = [
14 {
15 id: 'everywhere',
16 title: 'Everywhere',
17 keys: [
18 ['Tab', 'switch tab: files ⇄ agents (or click a tab)'],
19 ['?', 'this help'],
20 ['f ctrl+p', 'find a file by name'],
21 ['Esc', 'hand the keyboard back to the prompt'],
22 ],
23 },
24 {
25 id: 'list',
26 title: 'File list',
27 keys: [
28 ['↑↓ j k', 'move'],
29 ['PgUp PgDn Home End', 'page, first, last'],
30 ['⏎ → l', 'open'],
31 ['⌫ ← h', 'parent folder (clears a filter first)'],
32 ['/', 'filter by name (⏎ or Tab to finish)'],
33 ['.', 'show / hide dotfiles'],
34 ['t', 'only touched files'],
35 ['r', 'refresh'],
36 ['e', 'open the file in edit mode'],
37 ['@', 'insert @path into your prompt'],
38 ],
39 },
40 {
41 id: 'file',
42 title: 'File view',
43 keys: [
44 ['↑↓ j k', 'scroll'],
45 ['PgUp PgDn Space', 'page'],
46 ['g G Home End', 'top, end'],
47 ['e', 'edit'],
48 ['d', 'diff against the last commit'],
49 ['m', 'markdown: rendered ⇄ source'],
50 ['click, shift+click, drag', 'pick lines'],
51 ['@', 'mention the file (or the picked lines)'],
52 ['r', 'refresh the git marks'],
53 ['⌫ ← h q', 'back to the list'],
54 ],
55 },
56 {
57 id: 'diff',
58 title: 'Diff view',
59 keys: [
60 ['↑↓ PgUp PgDn g G', 'scroll'],
61 ['d ⌫', 'back to the file'],
62 ['@', 'mention the file'],
63 ],
64 },
65 {
66 id: 'edit',
67 title: 'Edit mode',
68 keys: [
69 ['ctrl+s', 'save'],
70 ['ctrl+z', 'undo'],
71 ['ctrl+e', 'back to view mode (twice to discard edits)'],
72 ['⏎', 'new line, keeping the indentation'],
73 ['Home End', 'line start (indentation first), end'],
74 ['ctrl+Home ctrl+End', 'start, end of the file'],
75 ['click', 'place the cursor'],
76 ],
77 },
78 {
79 id: 'finder',
80 title: 'Finder',
81 keys: [
82 ['type', 'letters of the path, in order'],
83 ['↑↓ ctrl+n ctrl+p', 'pick'],
84 ['⏎ click', 'open'],
85 ['ctrl+u', 'clear'],
86 ['⌫ on an empty query', 'close'],
87 ],
88 },
89 {
90 id: 'agents',
91 title: 'Agents',
92 keys: [
93 ['↑↓ PgUp PgDn', 'scroll'],
94 ['c', 'clear the finished agents'],
95 ],
96 },
97]
98
99/** The help's lines: the section for where the person is first, then everywhere's keys, then the rest. */
100export function helpLines(ctx: HelpContext): HelpLine[] {
101 const order = [...SECTIONS].sort((a, b) => rank(a.id, ctx) - rank(b.id, ctx))
102 return order.flatMap((s, i) => [
103 ...(i > 0 ? [{ kind: 'gap' as const }] : []),
104 { kind: 'title' as const, text: s.title },
105 ...s.keys.map(([key, label]) => ({ kind: 'key' as const, key, label })),
106 ])
107}
108const rank = (id: Section['id'], ctx: HelpContext) => (id === ctx ? 0 : id === 'everywhere' ? 1 : 2)
109
110/** One key while the help shows: arrows and pages scroll it, anything else closes it. */
111export function reduceHelp(v: View, k: Key, total: number, room: number): View {
112 const last = Math.max(0, total - room)
113 const scroll = (top: number) => ({ ...v, helpTop: clamp(top, 0, last) })
114 if (k.key === 'down' || k.key === 'j') return scroll(v.helpTop + 1)
115 if (k.key === 'up' || k.key === 'k') return scroll(v.helpTop - 1)
116 if (k.key === 'pagedown' || k.key === 'space') return scroll(v.helpTop + Math.max(1, room - 1))
117 if (k.key === 'pageup') return scroll(v.helpTop - Math.max(1, room - 1))
118 return { ...v, help: false, helpTop: 0 }
119}
120hooks/explorer/finder.ts 85 lines1// Pure logic for Claudashboard: the fuzzy file finder's matching and keys. No `$` here, so it is testable.
2import type { Finder } from '../../types'
3import type { Key } from './model'
4import { clamp } from '../shared/format'
5
6/** The most files the finder searches, and the most results it ranks. */
7export const MAX_FILES = 20_000
8export const MAX_RESULTS = 200
9
10export type Match = { path: string; score: number; hits: number[] }
11
12const isBoundary = (path: string, i: number) => i === 0 || '/._- '.includes(path[i - 1] ?? '')
13
14/** The query's letters in order inside `from..` of `path` (case ignored), each at its earliest place; null when they are not all there. */
15function subsequence(lower: string, q: string, from: number): number[] | null {
16 const hits: number[] = []
17 let at = from
18 for (const ch of q) {
19 const i = lower.indexOf(ch, at)
20 if (i < 0) return null
21 hits.push(i)
22 at = i + 1
23 }
24 return hits
25}
26
27/**
28 * How well `query` matches `path`, as a fuzzy finder ranks it: the query's letters in order, preferring the
29 * file's own name, letters in a row, and the starts of words. Null when the letters are not all there.
30 */
31export function fuzzyMatch(query: string, path: string): Match | null {
32 const q = query.toLowerCase().replace(/\s+/g, '')
33 if (!q) return { path, score: 0, hits: [] }
34 const lower = path.toLowerCase()
35 const nameAt = lower.lastIndexOf('/') + 1
36 // The whole query inside the file's name beats letters spread over its folders.
37 const inName = subsequence(lower, q, nameAt)
38 const hits = inName ?? subsequence(lower, q, 0)
39 if (!hits) return null
40 let score = inName ? 20 : 0
41 hits.forEach((i, n) => {
42 score += 1
43 if (n > 0 && hits[n - 1] === i - 1) score += 5
44 if (isBoundary(path, i)) score += 4
45 })
46 if (lower.slice(nameAt).startsWith(q)) score += 10
47 return { path, score: score - path.length / 100, hits }
48}
49
50/** The files matching `query`, best first (shorter paths first among equals); with no query, the files as listed. */
51export function findFiles(files: readonly string[], query: string, limit = MAX_RESULTS): Match[] {
52 if (!query.trim()) return files.slice(0, limit).map(path => ({ path, score: 0, hits: [] }))
53 const found: Match[] = []
54 for (const f of files) {
55 const m = fuzzyMatch(query, f)
56 if (m) found.push(m)
57 }
58 return found.sort((a, b) => b.score - a.score || a.path.length - b.path.length).slice(0, limit)
59}
60
61export type FinderEffect = { kind: 'open'; path: string } | { kind: 'close' } | null
62
63/** One key against the open finder: the next finder, and whether to open a file or close it. */
64export function reduceFinder(f: Finder, k: Key, room: number): { finder: Finder; effect: FinderEffect } {
65 const key = k.key
66 const results = findFiles(f.files, f.query)
67 const move = (c: number) => ({ finder: { ...f, cursor: clamp(c, 0, Math.max(0, results.length - 1)) }, effect: null })
68 if (key === 'return') {
69 const picked = results[clamp(f.cursor, 0, Math.max(0, results.length - 1))]
70 return { finder: f, effect: picked ? { kind: 'open', path: picked.path } : null }
71 }
72 if (key === 'down' || (k.ctrl && key === 'n')) return move(f.cursor + 1)
73 if (key === 'up' || (k.ctrl && key === 'p')) return move(f.cursor - 1)
74 if (key === 'pagedown') return move(f.cursor + Math.max(1, room - 1))
75 if (key === 'pageup') return move(f.cursor - Math.max(1, room - 1))
76 if (key === 'backspace') {
77 if (!f.query) return { finder: f, effect: { kind: 'close' } }
78 return { finder: { ...f, query: f.query.slice(0, -1), cursor: 0 }, effect: null }
79 }
80 if (k.ctrl && key === 'u') return { finder: { ...f, query: '', cursor: 0 }, effect: null }
81 if (key === 'space') return { finder: { ...f, query: `${f.query} `, cursor: 0 }, effect: null }
82 if (key.length === 1 && !k.ctrl && !k.meta) return { finder: { ...f, query: f.query + key, cursor: 0 }, effect: null }
83 return { finder: f, effect: null }
84}
85hooks/explorer/editor/editing.ts 99 lines1// The in-pane editor's lifecycle: entering and leaving edit mode, saving, undo.
2import type { EngineInterface } from 'claude-code'
3import type { Preview } from '../../../types'
4import { colFromVisual, reduceEdit, textOf } from './editor'
5import type { UiMessage } from '../client'
6import { hasNoteRow } from '../model'
7import { loadPreview } from '../files'
8import { MAX_UNDO, absOf, changedOnDisk, getExplorer, history, sameLines, saved, setPreview, toKey } from '../../state'
9
10/** Where the file view is scrolled, under the diff view when it is open. */
11const fileTopOf = (p: Preview) => p.diff?.fileTop ?? p.top
12
13export async function enterEdit($: EngineInterface) {
14 await setPreview($, p =>
15 p.readOnly
16 ? { ...p, status: `read-only: ${p.readOnly}` }
17 : {
18 ...p,
19 diff: null,
20 top: fileTopOf(p),
21 mode: 'edit',
22 row: Math.min(Math.max(p.row, fileTopOf(p)), Math.max(0, p.lines.length - 1)),
23 status: null,
24 confirm: null,
25 },
26 )
27}
28
29/** Back to view mode; with unsaved edits the first press warns and a second discards them. */
30export async function leaveEdit($: EngineInterface) {
31 const p = (await getExplorer($)).preview
32 if (!p) return
33 if (!p.dirty) return setPreview($, x => ({ ...x, mode: 'view', status: null, confirm: null }))
34 if (p.confirm !== 'discard') {
35 return setPreview($, x => ({ ...x, status: 'unsaved changes: ctrl+s saves, ctrl+e again discards them', confirm: 'discard' }))
36 }
37 // Discard: read the file again as it is on disk.
38 await loadPreview($, p.path, { top: p.top, row: p.row, col: p.col, status: 'changes discarded' })
39}
40
41export async function saveFile($: EngineInterface) {
42 const ex = await getExplorer($)
43 const p = ex.preview
44 if (!p || p.mode !== 'edit') return
45 if (p.readOnly) return setPreview($, x => ({ ...x, status: `read-only: ${p.readOnly}` }))
46 const abs = absOf(ex.root, p.path)
47 try {
48 const st = await $.fs.stat(abs).catch(() => null)
49 // Someone (Claude, another editor) changed the file since it was read: a second ctrl+s overwrites.
50 if (changedOnDisk(st, p) && p.confirm !== 'overwrite') {
51 return setPreview($, x => ({ ...x, status: 'the file changed on disk: ctrl+s again overwrites it, ctrl+e twice reloads it', confirm: 'overwrite' }))
52 }
53 await $.fs.write(abs, textOf(p))
54 const after = await $.fs.stat(abs).catch(() => null)
55 saved.set(p.path, p.lines)
56 await setPreview($, x => ({ ...x, dirty: false, mtimeMs: after?.mtimeMs ?? x.mtimeMs, status: 'saved ✓', confirm: null }))
57 $.ui.toast(`Saved ${p.path}`)
58 } catch (err) {
59 await setPreview($, x => ({ ...x, status: `cannot save: ${String(err)}`, confirm: null }))
60 }
61}
62
63export async function undoEdit($: EngineInterface, room: number) {
64 const p = (await getExplorer($)).preview
65 if (!p) return
66 const steps = history.get(p.path) ?? []
67 const last = steps.pop()
68 if (!last) return setPreview($, x => ({ ...x, status: 'nothing to undo' }))
69 const base = saved.get(p.path)
70 const isSaved = base !== undefined && sameLines(base, last.lines)
71 await setPreview($, x => {
72 const rows = Math.max(1, hasNoteRow(x) ? room - 1 : room)
73 const top = last.row < x.top || last.row >= x.top + rows ? Math.max(0, last.row - Math.floor(rows / 2)) : x.top
74 return { ...x, ...last, top, dirty: !isSaved, status: null, confirm: null }
75 })
76}
77
78/** One key, a click on the mode label, or a click in the text, while editing. */
79export async function editMessage($: EngineInterface, p: Preview, d: UiMessage, room: number) {
80 if (d.k === 'mode' || d.k === 'crumb') return leaveEdit($)
81 if (d.k === 'place' && typeof d.row === 'number' && typeof d.vis === 'number') {
82 const row = Math.min(p.top + d.row, Math.max(0, p.lines.length - 1))
83 const col = colFromVisual(p.lines[row] ?? '', Math.max(0, d.vis))
84 return setPreview($, x => ({ ...x, row, col, confirm: null }))
85 }
86 if (d.k !== 'key' || typeof d.key !== 'string') return
87 const r = reduceEdit(p, toKey(d), room)
88 if (r.effect === 'save') return saveFile($)
89 if (r.effect === 'exit') return leaveEdit($)
90 if (r.effect === 'undo') return undoEdit($, room)
91 if (r.changed) {
92 const steps = history.get(p.path) ?? []
93 steps.push({ lines: p.lines, row: p.row, col: p.col })
94 if (steps.length > MAX_UNDO) steps.shift()
95 history.set(p.path, steps)
96 }
97 if (r.p !== p) await setPreview($, () => r.p)
98}
99hooks/explorer/client.tsx 557 lines1// The explorer's surface module: draws what the hooks module hands it and posts keys and clicks back.
2// It holds no explorer state of its own; a click inside gives it the keyboard, Esc hands it back.
3import type { ClientModule } from 'claude-code'
4import type { DiffRow } from '../../types'
5import type { AgentLine } from '../agents/view'
6import type { MdKind, MdRow, MdStyle } from './media/markdown'
7import type { HelpLine } from '../help'
8import { agentsBody, finderBody, framed, helpBody, tabAt, tabStrip } from './views'
9
10export type GitLetter = { letter: string; tone: 'staged' | 'modified' | 'untracked' | 'deleted' | 'conflict' }
11
12export type Row = {
13 icon: string
14 label: string
15 meta: string
16 isDir: boolean
17 isTouched: boolean
18 isSelected: boolean
19 /** What Claude is doing with the file (or something under the folder) this turn. */
20 activity: 'read' | 'edit' | null
21 /** The file's git status letter, or a dot on a folder holding changes. */
22 git: GitLetter | null
23}
24/** One gutter cell beside a line: a bar coloured by what changed it, a low bar where lines were deleted. */
25export type GutterMark = { glyph: string; tone: 'unsaved' | 'added' | 'modified' | 'deleted' | 'staged' }
26
27export type Changes = { unsaved: number; added: number; modified: number; deleted: number; staged: number; repo: boolean }
28
29export type Palette = {
30 accent: string
31 dir: string
32 touched: string
33 dim: string
34 text: string
35 selectedBg: string
36 hoverBg: string
37 warn: string
38 pillFg: string
39 ok: string
40 bad: string
41 purple: string
42 info: string
43}
44
45export type Hint = { key: string; label: string }
46
47/** What the explorer posts back to the hooks module: every message carries the rows it shows. */
48export type UiMessage =
49 | { k: 'key'; key: string; ctrl: boolean; meta: boolean; room: number; width?: number }
50 | { k: 'mode' | 'crumb'; room: number }
51 | { k: 'place'; row: number; vis: number; room: number }
52 | { k: 'click'; index: number; room: number }
53 | { k: 'line'; row: number; isExtend: boolean; room: number }
54 | { k: 'tab'; tab: 'files' | 'agents'; room: number }
55 | { k: 'pick'; index: number; room: number }
56
57export type ExplorerProps = {
58 mode: 'list' | 'preview'
59 crumbs: string[]
60 counter: string
61 filter: string | null
62 hints: Hint[]
63 rows: Row[]
64 top: number
65 room: number
66 width: number
67 code: { source: string; path: string; startLine: number } | null
68 note: string | null
69 empty: string | null
70 colors: Palette
71 /** The open file's mode, drawn as the label at the top right; null in the list. */
72 fileMode: 'view' | 'edit' | 'readonly' | null
73 /** Why the open file cannot be edited: drawn beside the READ ONLY label. */
74 readOnly: string | null
75 isDirty: boolean
76 /** The header as fitted to the width by the hooks module: the mode label, the shortcut beside it, the unsaved mark. */
77 label: string | null
78 labelHint: Hint | null
79 dirtyMark: string | null
80 /** The git gutter for the lines in view (null: no data yet), and the file's change counts. */
81 gutter: Array<GutterMark | null> | null
82 changes: Changes | null
83 /** Rendered markdown's rows in view, when a markdown file shows rendered. */
84 markdown: MdRow[] | null
85 /** The editor's window: display lines (tabs expanded), the cursor in it, the gutter and text widths. */
86 edit: EditView | null
87 /** The picked lines, first and last, 1-based; null when none. */
88 selection: [number, number] | null
89 /** The diff view's rows in view: hunk headers, shared lines, removed and added lines. */
90 diff: DiffRow[] | null
91 /** The tab bar: which tab shows, how many agents run, and the project bar at its right (in a repository). */
92 tab?: 'files' | 'agents'
93 running?: number
94 repo?: RepoBar | null
95 /** What the body draws: the files (list or file), the agents tab, the finder, or the help. */
96 body?: 'files' | 'agents' | 'finder' | 'help'
97 /** The box's frame colour: it says which tab or mode shows. */
98 frame?: string
99 /** The clock when drawn: running agents' timers tick on from it. */
100 now?: number
101 agents?: AgentLine[] | null
102 help?: HelpLine[] | null
103 found?: FoundRow[] | null
104}
105
106export type RepoBar = { name: string; branch: string; ahead: number; behind: number; changed: number; staged: number }
107/** One finder result: its path, the matched letters' places in it, and whether it is picked. */
108export type FoundRow = { path: string; hits: number[]; isSelected: boolean }
109
110export type EditView = {
111 lines: string[]
112 startLine: number
113 cursorRow: number
114 cursorVis: number
115 hoff: number
116 gutterW: number
117 textW: number
118}
119
120/** The props as last drawn, the line a drag last reached (so a drag posts once per line), and the seconds ticked since `now`. */
121type Ref = { props: ExplorerProps; dragRow?: number | null; ticks: number; lastNow: number }
122type State = { ref: Ref }
123
124/** Rows above the list, inside the box: the breadcrumb and a rule. */
125const HEAD = 2
126/** Where the box's inside starts in the region: the two rows of the tab strip and the top border down, the border and padding across. */
127const TOP = 3
128/** While agents run, the client ticks this often: the agents tab's spinner turns and their timers count. */
129const TICK_MS = 200
130const LEFT = 2
131
132const FALLBACK: Palette = {
133 accent: 'claude',
134 dir: 'suggestion',
135 touched: 'warning',
136 dim: 'inactive',
137 text: 'text',
138 selectedBg: 'subtle',
139 hoverBg: 'subtle',
140 warn: 'warning',
141 pillFg: 'inverseText',
142 ok: 'success',
143 bad: 'error',
144 purple: 'permission',
145 info: 'suggestion',
146}
147
148/** Props as they may arrive: from an older load of the hooks module, or partial. Never throws. */
149function complete(raw: Partial<ExplorerProps> | null | undefined): ExplorerProps {
150 const p = raw ?? {}
151 const arr = <T,>(x: T[] | undefined): T[] => (Array.isArray(x) ? x : [])
152 return {
153 mode: p.mode === 'preview' ? 'preview' : 'list',
154 crumbs: arr(p.crumbs),
155 counter: typeof p.counter === 'string' ? p.counter : '',
156 filter: typeof p.filter === 'string' ? p.filter : null,
157 hints: arr(p.hints),
158 rows: arr(p.rows).map(r => ({ ...r, git: r.git ?? null })),
159 top: typeof p.top === 'number' ? p.top : 0,
160 room: typeof p.room === 'number' ? p.room : 10,
161 width: typeof p.width === 'number' ? p.width : 60,
162 code: p.code && typeof p.code.source === 'string' ? p.code : null,
163 note: typeof p.note === 'string' ? p.note : null,
164 empty: typeof p.empty === 'string' ? p.empty : null,
165 colors: { ...FALLBACK, ...(p.colors ?? {}) },
166 fileMode: p.fileMode === 'view' || p.fileMode === 'edit' || p.fileMode === 'readonly' ? p.fileMode : null,
167 readOnly: typeof p.readOnly === 'string' ? p.readOnly : null,
168 isDirty: p.isDirty === true,
169 label: typeof p.label === 'string' ? p.label : null,
170 labelHint: p.labelHint && typeof p.labelHint.key === 'string' ? p.labelHint : null,
171 dirtyMark: typeof p.dirtyMark === 'string' ? p.dirtyMark : null,
172 gutter: Array.isArray(p.gutter) ? p.gutter : null,
173 changes: p.changes && typeof p.changes.added === 'number' ? p.changes : null,
174 markdown: Array.isArray(p.markdown) ? p.markdown : null,
175 edit: p.edit && Array.isArray(p.edit.lines) ? p.edit : null,
176 diff: Array.isArray(p.diff) ? p.diff : null,
177 selection: Array.isArray(p.selection) && p.selection.length === 2 ? p.selection : null,
178 tab: p.tab === 'agents' ? 'agents' : 'files',
179 running: typeof p.running === 'number' ? p.running : 0,
180 repo: p.repo && typeof p.repo.name === 'string' ? p.repo : null,
181 body: p.body === 'agents' || p.body === 'finder' || p.body === 'help' ? p.body : 'files',
182 now: typeof p.now === 'number' ? p.now : 0,
183 frame: typeof p.frame === 'string' ? p.frame : undefined,
184 agents: Array.isArray(p.agents) ? p.agents : null,
185 help: Array.isArray(p.help) ? p.help : null,
186 found: Array.isArray(p.found) ? p.found : null,
187 }
188}
189
190const Explorer: ClientModule<ExplorerProps, State> = (rawProps, surface) => {
191 const { Box, Text, Code } = surface.elements
192 const props = complete(rawProps)
193 const ref = surface.state?.ref ?? { props, ticks: 0, lastNow: -1 }
194 ref.props = props
195 if (props.now !== ref.lastNow) {
196 ref.lastNow = props.now ?? 0
197 ref.ticks = 0
198 }
199
200 if (surface.state === undefined) {
201 surface.setState({ ref })
202 const post = (m: UiMessage) => surface.post(m)
203 // While agents run, the client ticks: the agents tab's spinner turns and their timers count, with no redraw of the pane.
204 surface.every(TICK_MS, () => {
205 if (!ref.props.running) return
206 ref.ticks += 1
207 surface.setState({ ref })
208 })
209 surface.onKey(k => {
210 post({ k: 'key', key: k.key, ctrl: k.ctrl ?? false, meta: k.meta ?? false, room: ref.props.room, width: ref.props.width })
211 })
212 surface.onPointer(raw => {
213 const cur = ref.props
214 // The tab strip: a click on a tab shows it.
215 if (raw.y < TOP - 1) {
216 const tab = raw.type === 'down' && raw.button === 'left' ? tabAt(raw.x, Math.max(20, cur.width) + 2 * LEFT, cur.running ?? 0) : null
217 if (tab) post({ k: 'tab', tab, room: cur.room })
218 return
219 }
220 // From here on, places inside the box.
221 const p = { ...raw, x: raw.x - LEFT, y: raw.y - TOP }
222 if (cur.body === 'finder') {
223 const i = p.y - HEAD
224 if (p.type === 'down' && p.button === 'left' && i >= 0 && i < (cur.found ?? []).length) post({ k: 'pick', index: cur.top + i, room: cur.room })
225 else if (p.type === 'down' && p.button === 'left' && p.y === 0) post({ k: 'crumb', room: cur.room })
226 return
227 }
228 if (cur.body !== 'files') {
229 if (p.type === 'down' && p.button === 'left' && p.y === 0) post({ k: 'crumb', room: cur.room })
230 return
231 }
232 // In the file view, a press picks a line and a drag (or shift+click) stretches the pick.
233 const codeRow = p.y - HEAD - (cur.note ? 1 : 0)
234 const isOnCode = cur.code !== null && !cur.edit && codeRow >= 0 && codeRow < cur.code.source.split('\n').length
235 if (p.type === 'move' && p.button === 'left') {
236 if (isOnCode && codeRow !== ref.dragRow) {
237 ref.dragRow = codeRow
238 post({ k: 'line', row: codeRow, isExtend: true, room: cur.room })
239 }
240 return
241 }
242 if (p.type !== 'down' || p.button !== 'left') return
243 ref.dragRow = null
244 // The mode label toggles view and edit; the rest of the breadcrumb goes back.
245 const label = cur.label ?? ''
246 if (p.y === 0 && label && p.x >= Math.max(10, cur.width) - label.length) return post({ k: 'mode', room: cur.room })
247 if (p.y === 0) return post({ k: 'crumb', room: cur.room })
248 if (cur.edit) {
249 const row = p.y - HEAD - (cur.note ? 1 : 0)
250 if (row < 0 || row >= cur.edit.lines.length) return
251 const vis = p.x - cur.edit.gutterW - 4 + (row === cur.edit.cursorRow ? cur.edit.hoff : 0)
252 return post({ k: 'place', row, vis, room: cur.room })
253 }
254 if (isOnCode) {
255 ref.dragRow = codeRow
256 return post({ k: 'line', row: codeRow, isExtend: p.shift === true, room: cur.room })
257 }
258 const i = p.y - HEAD
259 if (cur.mode === 'list' && i >= 0 && i < cur.rows.length) post({ k: 'click', index: cur.top + i, room: cur.room })
260 })
261 }
262
263 const c = props.colors
264 const markColor = (t: GutterMark['tone']) =>
265 t === 'unsaved' ? c.warn : t === 'added' ? c.ok : t === 'modified' ? c.accent : t === 'deleted' ? c.bad : c.purple
266 const letterColor = (t: GitLetter['tone']) => (t === 'staged' || t === 'untracked' ? c.ok : t === 'modified' ? c.warn : c.bad)
267 const markAt = (i: number) => {
268 const m = props.gutter?.[i]
269 return m ? <Text color={markColor(m.tone)}>{m.glyph}</Text> : <Text> </Text>
270 }
271 /** A changed line's number takes its change's colour, bold; the rest stay dim. */
272 const numberColor = (i: number) => {
273 const m = props.gutter?.[i]
274 return m && m.tone !== 'deleted' ? markColor(m.tone) : undefined
275 }
276 const W = Math.max(20, props.width)
277 /** How a markdown span draws: headings in the accent, code in the warm colour, links underlined, rules and borders dim. */
278 const mdStyle = (kind: MdKind, style: MdStyle) => ({
279 color:
280 style === 'dim'
281 ? c.dim
282 : style === 'code'
283 ? c.warn
284 : style === 'link'
285 ? c.accent
286 : kind === 'h1' || kind === 'h2'
287 ? c.accent
288 : kind === 'h3'
289 ? c.purple
290 : kind === 'quote'
291 ? c.dim
292 : c.text,
293 bold: style === 'bold' || style === 'boldItalic' || kind === 'h1' || kind === 'h2' || kind === 'h3',
294 italic: style === 'italic' || style === 'boldItalic' || kind === 'quote',
295 underline: style === 'link',
296 strikethrough: style === 'strike',
297 })
298 const metaW = props.mode === 'list' ? Math.min(20, Math.max(0, W - 24)) : 0
299 const nameW = Math.max(6, W - metaW - 6)
300
301 const ch = props.changes
302 const pillBg = props.fileMode === 'edit' ? c.warn : props.fileMode === 'view' ? c.accent : c.dim
303 const crumbs = (
304 <Box flexDirection="row" justifyContent="space-between" width={W}>
305 <Box flexShrink={1}>
306 <Text wrap="truncate-end">
307 {props.crumbs.map((part, i) => (
308 <Text>
309 {i > 0 ? <Text color={c.dim}> › </Text> : null}
310 <Text color={i === props.crumbs.length - 1 ? c.accent : c.dim} bold={i === props.crumbs.length - 1}>
311 {part}
312 </Text>
313 </Text>
314 ))}
315 {props.filter !== null ? <Text color={c.warn}>{` ⌕ ${props.filter}`}</Text> : null}
316 </Text>
317 </Box>
318 <Box flexShrink={0}>
319 <Text>
320 {props.dirtyMark ? <Text color={c.warn}>{`${props.dirtyMark} `}</Text> : null}
321 {props.labelHint ? (
322 <Text>
323 <Text color={c.accent} bold>
324 {props.labelHint.key}
325 </Text>
326 <Text color={c.dim}>{` ${props.labelHint.label} `}</Text>
327 </Text>
328 ) : null}
329 {props.label ? (
330 <Text backgroundColor={pillBg} color={c.pillFg} bold>
331 {props.label}
332 </Text>
333 ) : null}
334 </Text>
335 </Box>
336 </Box>
337 )
338
339 const list = (
340 <Box flexDirection="column" height={props.room}>
341 {props.empty ? (
342 <Text color={c.dim} italic wrap="truncate-end">
343 {` ${props.empty}`}
344 </Text>
345 ) : null}
346 {props.rows.map((r, i) => {
347 const bg = r.isSelected ? c.selectedBg : undefined
348 // What Claude does this turn comes first (yellow while it edits, purple while it reads); then, as in
349 // VS Code, a changed file's name takes its git colour.
350 const actColor = r.activity === 'edit' ? c.touched : r.activity === 'read' ? c.purple : null
351 const nameColor = actColor ?? (r.git && r.git.letter !== '•' ? letterColor(r.git.tone) : r.isDir ? c.dir : r.isTouched ? c.touched : c.text)
352 const icon = r.activity === 'edit' ? '✎' : r.activity === 'read' ? '◉' : r.isTouched ? '✎' : r.icon
353 return (
354 <Box key={`row${i}`} flexDirection="row" width={W}>
355 <Text backgroundColor={bg} color={r.isSelected ? c.accent : c.dim} hover={{ backgroundColor: c.hoverBg }}>
356 {r.isSelected ? '▌' : ' '}
357 </Text>
358 <Text
359 backgroundColor={bg}
360 color={actColor ?? (r.isTouched ? c.touched : r.isDir ? c.dir : c.dim)}
361 bold={actColor !== null}
362 hover={{ backgroundColor: c.hoverBg }}
363 >
364 {`${icon} `}
365 </Text>
366 <Text backgroundColor={bg} color={r.git ? letterColor(r.git.tone) : c.dim} bold hover={{ backgroundColor: c.hoverBg }}>
367 {`${r.git?.letter ?? ' '} `}
368 </Text>
369 <Box width={nameW}>
370 <Text
371 backgroundColor={bg}
372 color={nameColor}
373 bold={r.isSelected || r.isDir || actColor !== null}
374 wrap="truncate-end"
375 hover={{ backgroundColor: c.hoverBg }}
376 >
377 {r.label.padEnd(nameW)}
378 </Text>
379 </Box>
380 {metaW > 0 ? (
381 <Box width={metaW} justifyContent="flex-end">
382 <Text backgroundColor={bg} color={c.dim} wrap="truncate-start" hover={{ backgroundColor: c.hoverBg }}>
383 {r.meta.padStart(metaW)}
384 </Text>
385 </Box>
386 ) : null}
387 </Box>
388 )
389 })}
390 </Box>
391 )
392
393 const ev = props.edit
394 const editor = ev ? (
395 <Box flexDirection="column">
396 {ev.lines.map((line, i) => {
397 const gutter = `${String(ev.startLine + i).padStart(ev.gutterW)} │ `
398 if (i !== ev.cursorRow) {
399 return (
400 <Text wrap="truncate-end">
401 {markAt(i)}
402 <Text color={numberColor(i) ?? c.dim} bold={numberColor(i) !== undefined}>
403 {gutter}
404 </Text>
405 <Text color={c.text}>{line.slice(0, ev.textW)}</Text>
406 </Text>
407 )
408 }
409 const shown = line.slice(ev.hoff)
410 const at = ev.cursorVis - ev.hoff
411 const before = shown.slice(0, at)
412 const under = shown[at] ?? ' '
413 const after = shown.slice(at + 1, Math.max(at + 1, ev.textW))
414 return (
415 <Text wrap="truncate-end">
416 {markAt(i)}
417 <Text color={numberColor(i) ?? c.warn} bold>
418 {gutter}
419 </Text>
420 <Text backgroundColor={c.selectedBg} color={c.text}>
421 {before}
422 </Text>
423 <Text inverse color={c.warn}>
424 {under}
425 </Text>
426 <Text backgroundColor={c.selectedBg} color={c.text}>
427 {after.padEnd(Math.max(0, ev.textW - before.length - 1))}
428 </Text>
429 </Text>
430 )
431 })}
432 </Box>
433 ) : null
434
435 const isPicked = (line: number) => props.selection !== null && line >= props.selection[0] && line <= props.selection[1]
436
437 // The diff view: the new line's number (the old one's on a removed line), a +/- mark, the text.
438 const diffNumW = Math.max(3, ...(props.diff ?? []).map(r => String(r.new ?? r.old ?? '').length))
439 const diffView = props.diff ? (
440 <Box flexDirection="column">
441 {props.diff.map(r =>
442 r.kind === 'hunk' ? (
443 <Text color={c.accent} dimColor wrap="truncate-end">
444 {r.text}
445 </Text>
446 ) : (
447 <Text wrap="truncate-end">
448 <Text color={c.dim}>{`${String(r.new ?? r.old ?? '').padStart(diffNumW)} `}</Text>
449 <Text color={r.kind === 'add' ? c.ok : r.kind === 'del' ? c.bad : c.dim} bold>
450 {r.kind === 'add' ? '+' : r.kind === 'del' ? '−' : ' '}
451 </Text>
452 <Text color={r.kind === 'add' ? c.ok : r.kind === 'del' ? c.bad : c.text}>{` ${r.text || ' '}`}</Text>
453 </Text>
454 ),
455 )}
456 </Box>
457 ) : null
458
459 const numW = Math.max(3, String((props.code?.startLine ?? 1) + Math.max(0, (props.code?.source.split('\n').length ?? 1) - 1)).length)
460 const preview = (
461 <Box flexDirection="column" height={props.room}>
462 {props.note ? (
463 <Text color={c.warn} wrap="truncate-end">
464 {`⚠ ${props.note}`}
465 </Text>
466 ) : null}
467 {editor}
468 {diffView}
469 {props.markdown !== null ? (
470 <Box flexDirection="column" width={W} height={Math.max(1, props.room - (props.note ? 1 : 0))} overflow="hidden">
471 {props.markdown.map(r => (
472 <Text wrap="truncate-end">{r.spans.length === 0 ? ' ' : r.spans.map(s => <Text {...mdStyle(r.kind, s.style)}>{s.text}</Text>)}</Text>
473 ))}
474 </Box>
475 ) : null}
476 {props.code && props.code.source ? (
477 <Box flexDirection="row" width={W}>
478 <Box flexDirection="column" width={numW + 3} flexShrink={0}>
479 {props.code.source.split('\n').map((_, i) => (
480 <Text>
481 {markAt(i)}
482 <Text
483 color={isPicked((props.code?.startLine ?? 1) + i) ? c.accent : (numberColor(i) ?? c.dim)}
484 backgroundColor={isPicked((props.code?.startLine ?? 1) + i) ? c.selectedBg : undefined}
485 bold={isPicked((props.code?.startLine ?? 1) + i) || numberColor(i) !== undefined}
486 >
487 {String((props.code?.startLine ?? 1) + i).padStart(numW)}
488 </Text>
489 <Text color={isPicked((props.code?.startLine ?? 1) + i) ? c.accent : c.dim}>{isPicked((props.code?.startLine ?? 1) + i) ? ' ┃' : ' │'}</Text>
490 </Text>
491 ))}
492 </Box>
493 <Box flexDirection="column" width={Math.max(1, W - numW - 4)} marginLeft={1}>
494 <Code source={props.code.source} path={props.code.path} wrap="truncate-end" />
495 </Box>
496 </Box>
497 ) : null}
498 </Box>
499 )
500
501 return (
502 <Box flexDirection="column" width={W + 2 * LEFT}>
503 {tabStrip(surface.elements, props, W + 2 * LEFT, ref.ticks)}
504 {framed(
505 surface.elements,
506 props,
507 W + 2 * LEFT,
508 props.room + HEAD + 1,
509 <Box flexDirection="column" width={W}>
510 {crumbs}
511 <Text color={c.dim}>{'─'.repeat(W)}</Text>
512 {props.body === 'agents'
513 ? agentsBody(surface.elements, props, W, ref.ticks * TICK_MS)
514 : props.body === 'help'
515 ? helpBody(surface.elements, props)
516 : props.body === 'finder'
517 ? finderBody(surface.elements, props, W)
518 : props.mode === 'list'
519 ? list
520 : preview}
521 <Box flexDirection="row" justifyContent="space-between" width={W}>
522 <Box flexShrink={1}>
523 <Text wrap="truncate-end">
524 {props.hints.map((hint, i) => (
525 <Text>
526 {i > 0 ? <Text color={c.dim}>{' '}</Text> : null}
527 <Text color={c.accent} bold>
528 {hint.key}
529 </Text>
530 <Text color={c.dim}>{` ${hint.label}`}</Text>
531 </Text>
532 ))}
533 </Text>
534 </Box>
535 {props.counter || ch ? (
536 <Box flexShrink={0} marginLeft={2}>
537 <Text>
538 {ch && ch.unsaved ? <Text color={c.warn}>{`● ${ch.unsaved} unsaved `}</Text> : null}
539 {ch && ch.added ? <Text color={c.ok}>{`+${ch.added} `}</Text> : null}
540 {ch && ch.modified ? <Text color={c.accent}>{`~${ch.modified} `}</Text> : null}
541 {ch && ch.deleted ? <Text color={c.bad}>{`−${ch.deleted} `}</Text> : null}
542 {ch && ch.staged ? <Text color={c.purple}>{`✓${ch.staged} staged `}</Text> : null}
543 {ch && !ch.repo && (ch.added || ch.modified || ch.deleted) ? <Text color={c.dim}>{'since opened '}</Text> : null}
544 {ch && (ch.unsaved || ch.added || ch.modified || ch.deleted || ch.staged) ? <Text color={c.dim}> </Text> : null}
545 <Text color={c.dim}>{props.counter}</Text>
546 </Text>
547 </Box>
548 ) : null}
549 </Box>
550 </Box>,
551 )}
552 </Box>
553 )
554}
555
556export default Explorer
557hooks/explorer/model.ts 241 lines1// Pure logic for Claudashboard: the explorer's state, its preview, and its key reducer. No `$` here, so it is testable.
2import type { Entry, Explorer, Preview } from '../../types'
3import { clamp, clipEnd, fmtSize } from '../shared/format'
4import { renderMarkdown, rowOfLine } from './media/markdown'
5
6export const DEFAULT_EXPLORER: Explorer = {
7 root: '',
8 dir: '',
9 entries: [],
10 cursor: 0,
11 top: 0,
12 filter: '',
13 isFiltering: false,
14 showHidden: false,
15 onlyTouched: false,
16 preview: null,
17 error: null,
18 finder: null,
19}
20
21/** The largest file the preview reads, and the most lines it keeps. */
22export const MAX_PREVIEW_BYTES = 1024 * 1024
23export const MAX_PREVIEW_LINES = 5000
24/** Lines longer than this (minified code) are cut and make the file read-only. */
25const MAX_LINE = 10_000
26/** The most of one line the view mode draws: the rest of a long line is off screen anyway. */
27export const MAX_DRAWN = 1000
28
29export function sortEntries(list: Entry[]): Entry[] {
30 return [...list].sort((a, b) => {
31 const da = a.kind === 'dir' ? 0 : 1
32 const db = b.kind === 'dir' ? 0 : 1
33 return da - db || a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
34 })
35}
36
37/** The rows the list shows: hidden files and the filter applied. */
38export function visibleEntries(ex: Explorer): Entry[] {
39 const f = ex.filter.toLowerCase()
40 return ex.entries.filter(e => (ex.showHidden || ex.onlyTouched || !e.name.startsWith('.')) && (!f || e.name.toLowerCase().includes(f)))
41}
42
43/** The first visible row so the cursor stays in a window of `room` rows. */
44export function windowTop(cursor: number, count: number, room: number): number {
45 if (count <= room) return 0
46 return Math.max(0, Math.min(cursor - Math.floor(room / 3), count - room))
47}
48
49/** A preview with every field present: new, or stored by an older version. */
50export function emptyPreview(path: string, over: Partial<Preview> = {}): Preview {
51 return {
52 path,
53 lines: [],
54 top: 0,
55 note: null,
56 mode: 'view',
57 row: 0,
58 col: 0,
59 dirty: false,
60 mtimeMs: 0,
61 eol: true,
62 crlf: false,
63 readOnly: null,
64 status: null,
65 confirm: null,
66 kind: 'text',
67 mdView: 'rendered',
68 image: null,
69 diff: null,
70 sel: null,
71 ...over,
72 }
73}
74
75/** Whether a note or status line sits above the code, taking one of its rows. */
76export const hasNoteRow = (p: Preview) => Boolean(p.note || p.status || p.readOnly)
77
78/**
79 * Text the preview can draw: tab and newline the only control characters, long lines cut.
80 * Whatever had to be altered to draw it makes the file read-only, so a save never writes the altered text.
81 */
82export function previewOf(path: string, text: string, size: number, mtimeMs = 0): Preview {
83 if (size > MAX_PREVIEW_BYTES) return emptyPreview(path, { note: `${fmtSize(size)}: too large to preview`, readOnly: 'too large', mtimeMs })
84 if (text.slice(0, 8000).includes('\u0000')) return emptyPreview(path, { note: `binary file, ${fmtSize(size)}`, readOnly: 'binary file', mtimeMs })
85 const crlf = text.includes('\r\n')
86 const all = text.replace(/\r\n?/g, '\n').split('\n')
87 const eol = all.length > 1 && all[all.length - 1] === ''
88 if (eol) all.pop()
89 const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/ // no g: .test must not carry lastIndex between lines
90 let readOnly: string | null = null
91 if (/\r(?!\n)/.test(text) || (crlf && /(?<!\r)\n/.test(text))) readOnly = 'mixed line endings'
92 else if (all.some(l => CONTROL.test(l))) readOnly = 'control characters in the file'
93 else if (all.some(l => l.length > MAX_LINE)) {
94 readOnly = `line ${all.findIndex(l => l.length > MAX_LINE) + 1} is longer than ${MAX_LINE} characters`
95 } else if (all.length > MAX_PREVIEW_LINES) readOnly = `longer than ${MAX_PREVIEW_LINES} lines`
96 const strip = new RegExp(CONTROL, 'g')
97 const lines = all.slice(0, MAX_PREVIEW_LINES).map(l => clipEnd(l.replace(strip, ''), MAX_LINE))
98 const note = all.length > MAX_PREVIEW_LINES ? `showing the first ${MAX_PREVIEW_LINES} of ${all.length} lines` : null
99 return emptyPreview(path, { lines: lines.length ? lines : [''], note, eol, crlf, readOnly, mtimeMs })
100}
101
102export type Effect =
103 | { kind: 'open'; entry: Entry; edit?: true }
104 | { kind: 'edit' }
105 | { kind: 'up' }
106 | { kind: 'mention'; path: string }
107 | { kind: 'reload' }
108 | { kind: 'diff' }
109 | { kind: 'find' }
110 | null
111
112export type Key = { key: string; ctrl?: true; meta?: true; shift?: true }
113
114/**
115 * One key against the explorer: the next state, and the I/O the hooks module owes for it.
116 * `room` is how many rows the list or preview shows, for paging; `width` how wide, for rendered markdown.
117 */
118export function reduceKey(ex: Explorer, k: Key, room: number, width = 80): { ex: Explorer; effect: Effect } {
119 const page = Math.max(1, room - 1)
120 const key = k.key
121 const isFind = (key === 'f' && !k.ctrl && !k.meta) || (key === 'p' && k.ctrl === true)
122 if (isFind && !ex.isFiltering) return { ex, effect: { kind: 'find' } }
123
124 if (ex.preview?.diff) {
125 const p = ex.preview
126 const d = ex.preview.diff
127 const last = Math.max(0, d.rows.length - (hasNoteRow(p) ? room - 1 : room))
128 const scroll = (top: number) => ({ ex: { ...ex, preview: { ...p, top: clamp(top, 0, last) } }, effect: null })
129 // Back to the file where it was scrolled.
130 if (key === 'd' || key === 'backspace' || key === 'left' || key === 'h' || key === 'q') {
131 return { ex: { ...ex, preview: { ...p, diff: null, top: d.fileTop } }, effect: null }
132 }
133 if (key === 'down' || key === 'j') return scroll(p.top + 1)
134 if (key === 'up' || key === 'k') return scroll(p.top - 1)
135 if (key === 'pagedown' || key === 'space' || key === ' ') return scroll(p.top + page)
136 if (key === 'pageup') return scroll(p.top - page)
137 if (key === 'home' || key === 'g') return scroll(0)
138 if (key === 'end' || key === 'G') return scroll(last)
139 if (key === '@') return { ex, effect: { kind: 'mention', path: p.path } }
140 return { ex, effect: null }
141 }
142
143 if (ex.preview) {
144 const p = ex.preview
145 // A note above the code takes one of the rows. Rendered markdown scrolls until its last line is drawn.
146 const rows = hasNoteRow(p) ? room - 1 : room
147 const isRendered = p.kind === 'markdown' && p.mdView !== 'source'
148 const last = Math.max(0, (isRendered ? renderMarkdown(p.lines, width).length : p.lines.length) - rows)
149 const scroll = (top: number) => ({ ex: { ...ex, preview: { ...p, top: clamp(top, 0, last) } }, effect: null })
150 if (key === 'backspace' || key === 'left' || key === 'h' || key === 'q') return { ex, effect: { kind: 'up' } }
151 if (key === 'down' || key === 'j') return scroll(p.top + 1)
152 if (key === 'up' || key === 'k') return scroll(p.top - 1)
153 if (key === 'pagedown' || key === 'space' || key === ' ') return scroll(p.top + page)
154 if (key === 'pageup') return scroll(p.top - page)
155 if (key === 'home' || key === 'g') return scroll(0)
156 if (key === 'end' || key === 'G') return scroll(last)
157 if (key === '@') return { ex, effect: { kind: 'mention', path: mentionOf(p.path, p.sel ?? null) } }
158 if (key === 'e') return { ex, effect: { kind: 'edit' } }
159 if (key === 'r') return { ex, effect: { kind: 'reload' } }
160 if (key === 'd' && p.kind !== 'image') return { ex, effect: { kind: 'diff' } }
161 if (key === 'm' && p.kind === 'markdown') {
162 // The view stays where it was: rendered rows know their source line.
163 const md = renderMarkdown(p.lines, width)
164 const top = isRendered ? (md[p.top]?.src ?? 0) : rowOfLine(md, p.top)
165 return { ex: { ...ex, preview: { ...p, mdView: isRendered ? 'source' : 'rendered', top } }, effect: null }
166 }
167 return { ex, effect: null }
168 }
169
170 if (ex.isFiltering) {
171 if (key === 'return' || key === 'tab') return { ex: { ...ex, isFiltering: false }, effect: null }
172 if (key === 'backspace') {
173 if (!ex.filter) return { ex: { ...ex, isFiltering: false }, effect: null }
174 return { ex: { ...ex, filter: ex.filter.slice(0, -1), cursor: 0 }, effect: null }
175 }
176 if (key.length === 1 && !k.ctrl && !k.meta) return { ex: { ...ex, filter: ex.filter + key, cursor: 0 }, effect: null }
177 // Arrows still move while typing a filter.
178 }
179
180 const rows = visibleEntries(ex)
181 const n = rows.length
182 const move = (c: number) => ({ ex: { ...ex, cursor: clamp(c, 0, Math.max(0, n - 1)) }, effect: null })
183 const current = rows[clamp(ex.cursor, 0, Math.max(0, n - 1))]
184
185 if (key === 'down' || (!ex.isFiltering && key === 'j')) return move(ex.cursor + 1)
186 if (key === 'up' || (!ex.isFiltering && key === 'k')) return move(ex.cursor - 1)
187 if (key === 'pagedown') return move(ex.cursor + page)
188 if (key === 'pageup') return move(ex.cursor - page)
189 if (key === 'home') return move(0)
190 if (key === 'end') return move(n - 1)
191 if (key === 'return' || key === 'right' || (!ex.isFiltering && key === 'l')) {
192 return current ? { ex, effect: { kind: 'open', entry: current } } : { ex, effect: null }
193 }
194 if (ex.isFiltering) return { ex, effect: null }
195 if (key === 'backspace' || key === 'left' || key === 'h') {
196 if (ex.filter) return { ex: { ...ex, filter: '', cursor: 0 }, effect: null }
197 return { ex, effect: { kind: 'up' } }
198 }
199 if (key === '/') return { ex: { ...ex, isFiltering: true }, effect: null }
200 if (key === '.') return { ex: { ...ex, showHidden: !ex.showHidden, cursor: 0 }, effect: null }
201 if (key === 't') return { ex: { ...ex, onlyTouched: !ex.onlyTouched, filter: '', cursor: 0 }, effect: { kind: 'reload' } }
202 if (key === 'r') return { ex, effect: { kind: 'reload' } }
203 if (key === '@' && current) return { ex, effect: { kind: 'mention', path: current.path } }
204 if (key === 'e' && current && current.kind !== 'dir') return { ex, effect: { kind: 'open', entry: current, edit: true } }
205 return { ex, effect: null }
206}
207
208/** The touched-files view: every file Claude edited, most edits first. */
209export function touchedEntries(touched: Record<string, number>): Entry[] {
210 return Object.entries(touched)
211 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
212 .map(([path]) => ({ name: path, path, kind: 'file' as const, size: 0, mtimeMs: 0 }))
213}
214
215type Selection = { anchor: number; head: number }
216
217/** The selected lines, first and last, 0-based. */
218export const selRange = (s: Selection): [number, number] => [Math.min(s.anchor, s.head), Math.max(s.anchor, s.head)]
219
220/**
221 * A click on line `line` of the view: select it, or with `isExtend` (shift+click, a drag) stretch the
222 * selection to it from where it started. Clicking the one selected line again clears it.
223 */
224export function selectLine(p: Preview, line: number, isExtend: boolean): Preview {
225 const at = clamp(line, 0, Math.max(0, p.lines.length - 1))
226 const s = p.sel ?? null
227 if (isExtend && s) return { ...p, sel: { anchor: s.anchor, head: at } }
228 if (s && s.anchor === at && s.head === at) return { ...p, sel: null }
229 return { ...p, sel: { anchor: at, head: at } }
230}
231
232/**
233 * What `@` puts in the prompt: the file, and the selected lines when there are some, in the form Claude
234 * Code's own editor extension inserts for a selection (`src/a.ts#10-20`, one line as `#7-7`).
235 */
236export function mentionOf(path: string, sel: Selection | null): string {
237 if (!sel) return path
238 const [a, b] = selRange(sel)
239 return `${path}#${a + 1}-${b + 1}`
240}
241hooks/explorer/files.ts 228 lines1// The explorer's I/O: folder listings, the preview's file, and the live refresh.
2import type { EngineInterface } from 'claude-code'
3import type { Entry, Explorer, Preview, RepoInfo } from '../../types'
4import { MAX_PREVIEW_BYTES, emptyPreview, previewOf, sortEntries, touchedEntries, visibleEntries } from './model'
5import { bases, fetchBase, gitStatusOf, opened, repoFiles, repoStatus, withGit } from './git/git'
6import { MAX_FILES } from './finder'
7import { diffRows } from './git/gitdiff'
8import { kindOf } from './media/media'
9import { joinPath, parentOf, relTo, toSlash } from '../shared/paths'
10import { loadPicture } from './media/picture'
11import { absOf, changedOnDisk, getExplorer, logged, getTouched, history, paneState, saved, setPreview, updateExplorer, updateTouched } from '../state'
12
13/** Whether two listings show the same thing: names, sizes, times and git letters. */
14const sameListing = (a: Entry[], b: Entry[]) =>
15 a.length === b.length &&
16 a.every((e, i) => {
17 const o = b[i]
18 return o !== undefined && e.path === o.path && e.kind === o.kind && e.size === o.size && e.mtimeMs === o.mtimeMs && e.git === o.git
19 })
20
21let isPolling = false
22
23/**
24 * Every few seconds while the pane is open: the folder's listing and git status, and the open file
25 * if it changed on disk (edited in another editor). Writes, and so redraws, only what changed.
26 */
27export async function poll($: EngineInterface) {
28 if (isPolling || !paneState.isOpen) return
29 isPolling = true
30 try {
31 const ex = await getExplorer($)
32 if (!ex.root) return
33 if (ex.preview) {
34 const p = ex.preview
35 const st = await $.fs.stat(absOf(ex.root, p.path)).catch(() => null)
36 if (changedOnDisk(st, p)) await reloadChanged($, p, false)
37 await fetchBase($, ex.root, p.path)
38 }
39 const listing = await listingOf($, ex, ex.dir).catch(() => null)
40 if (!listing) return
41 const { entries, repo } = listing
42 const sameRepo = JSON.stringify(repo ?? null) === JSON.stringify(ex.repo ?? null)
43 if (sameRepo && sameListing(entries, ex.entries)) return
44 await updateExplorer($, cur => {
45 if (cur.dir !== ex.dir) return cur
46 // Keep the cursor on the same entry when files come and go.
47 // The cursor indexes the visible rows, not every entry.
48 const at = visibleEntries(cur)[cur.cursor]?.path
49 const next = { ...cur, entries, repo }
50 const rows = visibleEntries(next)
51 const idx = at ? rows.findIndex(e => e.path === at) : -1
52 return { ...next, cursor: idx >= 0 ? idx : Math.min(cur.cursor, Math.max(0, rows.length - 1)) }
53 })
54 } finally {
55 isPolling = false
56 }
57}
58
59async function listDir($: EngineInterface, root: string, dir: string): Promise<Entry[]> {
60 const raw = await $.fs.list(joinPath(root, dir))
61 const entries = await Promise.all(
62 raw.map(async r => {
63 const path = dir ? `${dir}/${r.name}` : r.name
64 let kind = r.kind
65 // A symbolic link lists as `other`: show it as what it leads to.
66 if (r.isLink) {
67 const s = await $.fs.stat(joinPath(root, path)).catch(() => undefined)
68 if (s) kind = s.kind
69 }
70 return { name: r.name, path, kind, size: r.size, mtimeMs: r.mtimeMs }
71 }),
72 )
73 return sortEntries(entries)
74}
75
76/**
77 * What the explorer lists, with git's letters: the folder `dir`, or the touched view. Inside a repository
78 * the touched view is what git reports changed, so a reverted file leaves it; outside git, the files Claude edited.
79 */
80async function listingOf($: EngineInterface, ex: Explorer, dir: string): Promise<{ entries: Entry[]; repo: RepoInfo | null }> {
81 if (!ex.onlyTouched) {
82 const listed = await listDir($, ex.root, dir)
83 return gitStatusOf($, ex.root, dir, listed).catch(() => ({ entries: listed, repo: null }))
84 }
85 const s = await repoStatus($, ex.root, '')
86 if (!s) return { entries: touchedEntries(await getTouched($)), repo: null }
87 // ponytail: one stat per changed file each poll; cap it if a repo without a .gitignore lists thousands.
88 const entries = await Promise.all(
89 s.repo.changedFiles.map(async path => {
90 const st = await $.fs.stat(absOf(ex.root, path)).catch(() => null)
91 return { name: path, path, kind: 'file' as const, size: st?.size ?? 0, mtimeMs: st?.mtimeMs ?? 0 }
92 }),
93 )
94 return { entries: withGit(entries, s, ex.root), repo: s.repo }
95}
96
97/** Loads `dir` into the explorer, the cursor on `select` when it is there. */
98export async function loadDir($: EngineInterface, dir: string, select?: string) {
99 const ex = await getExplorer($)
100 try {
101 const { entries, repo } = await listingOf($, ex, dir)
102 await updateExplorer($, x => {
103 const next: Explorer = { ...x, dir, entries, repo, preview: null, error: null, cursor: 0 }
104 const at = select ? visibleEntries(next).findIndex(e => e.path === select) : -1
105 return { ...next, cursor: Math.max(0, at) }
106 })
107 } catch (err) {
108 await updateExplorer($, x => ({ ...x, error: `cannot open ${dir || '.'}: ${String(err)}` }))
109 }
110}
111
112/** Reads `path` into the preview; `keep` carries the mode, cursor and scroll over a reload. */
113export async function loadPreview($: EngineInterface, path: string, keep: Partial<Preview> = {}) {
114 const ex = await getExplorer($)
115 const abs = absOf(ex.root, path)
116 try {
117 const st = await $.fs.stat(abs)
118 const kind = kindOf(path)
119 if (kind === 'image') {
120 const picture = await loadPicture($, abs, st.size, st.mtimeMs)
121 await updateExplorer($, x => ({
122 ...x,
123 error: null,
124 preview: emptyPreview(path, { kind: 'image', lines: [], mtimeMs: st.mtimeMs, readOnly: 'picture', ...picture }),
125 }))
126 return
127 }
128 const text = st.size > MAX_PREVIEW_BYTES ? '' : await $.fs.read(abs)
129 const fresh: Preview = { ...previewOf(path, text, st.size, st.mtimeMs), kind, mdView: keep.mdView ?? 'rendered' }
130 const isEdit = keep.mode === 'edit' && fresh.readOnly === null
131 const preview: Preview = {
132 ...fresh,
133 mode: isEdit ? 'edit' : 'view',
134 top: Math.min(keep.top ?? 0, Math.max(0, fresh.lines.length - 1)),
135 row: Math.min(keep.row ?? 0, Math.max(0, fresh.lines.length - 1)),
136 col: keep.col ?? 0,
137 status: keep.status ?? null,
138 }
139 history.delete(path)
140 saved.set(path, fresh.lines)
141 if (!opened.has(path)) opened.set(path, fresh.lines)
142 bases.delete(path)
143 void fetchBase($, ex.root, path).catch(logged($, 'fetchBase'))
144 await updateExplorer($, x => ({ ...x, preview, error: null }))
145 } catch (err) {
146 await updateExplorer($, x => ({ ...x, error: `cannot read ${path}: ${String(err)}` }))
147 }
148}
149
150/** The open file changed on disk: show the new text, or warn once when the person has unsaved edits. */
151export async function reloadChanged($: EngineInterface, p: Preview, isByClaude: boolean) {
152 if (!p.dirty) {
153 const status = isByClaude ? 'reloaded: Claude changed this file' : 'reloaded: changed on disk'
154 return loadPreview($, p.path, { mode: p.mode, top: p.top, row: p.row, col: p.col, status })
155 }
156 if (p.confirm === 'overwrite' || p.status?.includes('changed on disk')) return
157 const who = isByClaude ? 'Claude changed this file' : 'the file changed'
158 await setPreview($, x => ({ ...x, status: `${who} on disk: saving will ask before overwriting` }))
159}
160
161/** Records a file Claude edited, and shows it in the folder on screen. */
162export async function noteEdit($: EngineInterface, e: unknown) {
163 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
164 const given = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
165 if (!given) return
166 const abs = toSlash(given)
167 const ex = await getExplorer($)
168 const path = relTo(ex.root, abs)
169 await updateTouched($, t => ({ ...t, [path]: (t[path] ?? 0) + 1 }))
170 // The file open in the preview changed under it: show the new text, unless the person has unsaved edits.
171 if (ex.preview && ex.preview.path === path) await reloadChanged($, ex.preview, true)
172 // A new file in the folder on screen shows without a manual refresh.
173 if (!ex.preview && !ex.onlyTouched && parentOf(path) === ex.dir && !ex.entries.some(x => x.path === path)) await loadDir($, ex.dir)
174 if (ex.onlyTouched) await loadDir($, ex.dir)
175}
176
177/** The diff view of the file in the preview: its text against git's HEAD, or outside git the text as first opened. */
178export async function openDiff($: EngineInterface) {
179 const ex = await getExplorer($)
180 const p = ex.preview
181 if (!p) return
182 const base = bases.get(p.path)
183 if (!base) {
184 void fetchBase($, ex.root, p.path).catch(logged($, 'fetchBase'))
185 return setPreview($, x => ({ ...x, status: "reading git's copy of the file: press d again in a moment" }))
186 }
187 const rows = diffRows(base.head, p.lines)
188 if (rows.length === 0) return setPreview($, x => ({ ...x, status: base.repo ? 'no changes since the last commit' : 'no changes since the file was opened' }))
189 await setPreview($, x => ({ ...x, diff: { rows, fileTop: x.top }, top: 0, status: null }))
190}
191
192/** Folders a walk outside git never enters: they hold thousands of files nobody searches for. */
193const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'target', '.venv', '__pycache__'])
194
195/** Every file under the root outside git, folder by folder, dotfolders only with dotfiles shown; at most MAX_FILES. */
196async function walkFiles($: EngineInterface, root: string, showHidden: boolean): Promise<string[]> {
197 const files: string[] = []
198 const queue = ['']
199 while (queue.length && files.length < MAX_FILES) {
200 const dir = queue.shift() ?? ''
201 const raw = await $.fs.list(joinPath(root, dir)).catch(() => [])
202 for (const r of raw) {
203 if (SKIP.has(r.name) || (!showHidden && r.name.startsWith('.'))) continue
204 const path = dir ? `${dir}/${r.name}` : r.name
205 // A symbolic link lists as `other`: walk or list it as what it leads to.
206 const kind = r.isLink ? ((await $.fs.stat(joinPath(root, path)).catch(() => null))?.kind ?? null) : r.kind
207 if (kind === 'dir' && !r.isLink) queue.push(path)
208 else if (kind === 'file') files.push(path)
209 }
210 }
211 return files.slice(0, MAX_FILES)
212}
213
214/** Opens the fuzzy finder, then fills in the files it searches: git's list in a repository, else a walk. */
215export async function openFinder($: EngineInterface) {
216 const ex = await getExplorer($)
217 await updateExplorer($, x => ({ ...x, isFiltering: false, finder: { query: '', cursor: 0, files: x.finder?.files ?? [], isLoading: true } }))
218 const files = ((await repoFiles($, ex.root)) ?? (await walkFiles($, ex.root, ex.showHidden))).slice(0, MAX_FILES)
219 await updateExplorer($, x => (x.finder ? { ...x, finder: { ...x.finder, files, isLoading: false } } : x))
220}
221
222/** Opens a file the finder picked: its folder listed with the cursor on it, then the file itself. */
223export async function openFound($: EngineInterface, path: string) {
224 await updateExplorer($, x => ({ ...x, finder: null, onlyTouched: false, filter: '' }))
225 await loadDir($, parentOf(path), path)
226 await loadPreview($, path)
227}
228hooks/explorer/git/git.ts 107 lines1// git for the explorer: the status letters and project bar, and what the open file's gutter compares against.
2import type { EngineInterface } from 'claude-code'
3import type { Entry, RepoInfo } from '../../../types'
4import { changedUnder, countStatus, parseBranch, parsePorcelain, splitText } from './gitdiff'
5import { baseName, parentOf, relTo } from '../../shared/paths'
6import { absOf, bumpGitTick, getExplorer, sameLines, updateExplorer } from '../../state'
7
8/** What each open file is compared against: git's HEAD and index, or (outside git) the text as first opened. */
9export type Base = { repo: boolean; head: string[]; index: string[] }
10export const bases = new Map<string, Base>()
11/** The text of each file as first opened this session: the base outside git. */
12export const opened = new Map<string, string[]>()
13const fetching = new Set<string>()
14
15function git($: EngineInterface, cwd: string, args: string[]) {
16 return $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 }).catch(() => ({ exitCode: -1, stdout: '', stderr: '' }))
17}
18
19/** The repository's top folder holding `dir`, or null outside git. */
20async function repoTop($: EngineInterface, dir: string): Promise<string | null> {
21 const r = await git($, dir, ['rev-parse', '--show-toplevel'])
22 return r.exitCode === 0 ? r.stdout.trim() || null : null
23}
24
25/** Reads HEAD's and the index's copy of the open file, then redraws its gutter. */
26export async function fetchBase($: EngineInterface, root: string, path: string) {
27 if (fetching.has(path)) return
28 fetching.add(path)
29 try {
30 const abs = absOf(root, path)
31 const top = await repoTop($, parentOf(abs) || '/')
32 const first = opened.get(path) ?? []
33 let base: Base
34 if (!top) {
35 base = { repo: false, head: first, index: first }
36 } else {
37 const rel = relTo(top, abs)
38 const [head, index] = await Promise.all([git($, top, ['show', `HEAD:${rel}`]), git($, top, ['show', `:${rel}`])])
39 // Not in HEAD (a new file) or not in the index (untracked): every line counts as added.
40 base = {
41 repo: true,
42 head: head.exitCode === 0 ? splitText(head.stdout) : [],
43 index: index.exitCode === 0 ? splitText(index.stdout) : [],
44 }
45 }
46 const old = bases.get(path)
47 if (old && old.repo === base.repo && sameLines(old.head, base.head) && sameLines(old.index, base.index)) return
48 bases.set(path, base)
49 await bumpGitTick($)
50 } finally {
51 fetching.delete(path)
52 }
53}
54
55/** One `git status` of the repository holding `dir`: each changed file's two letters, keyed by its path from `top`. */
56export type RepoStatus = { top: string; status: Map<string, string>; repo: RepoInfo }
57
58/** The status of the repository holding `dir`, or null outside git. */
59export async function repoStatus($: EngineInterface, root: string, dir: string): Promise<RepoStatus | null> {
60 const top = await repoTop($, absOf(root, dir))
61 if (!top) return null
62 const r = await git($, top, ['status', '--porcelain=v1', '-b', '-z', '--untracked-files=all'])
63 if (r.exitCode !== 0) return null
64 const status = parsePorcelain(r.stdout)
65 const changedFiles = changedUnder(root, top, status)
66 return { top, status, repo: { name: baseName(top), ...parseBranch(r.stdout), ...countStatus(status), changedFiles } }
67}
68
69/** The entries with git's letters: a letter per changed file, a dot on folders holding changes. */
70export function withGit(entries: Entry[], s: RepoStatus | null, root: string): Entry[] {
71 const plain = entries.map(e => (e.git === undefined ? e : { ...e, git: undefined }))
72 if (!s) return plain
73 const keys = [...s.status.keys()]
74 return plain.map(e => {
75 const rel = relTo(s.top, absOf(root, e.path))
76 if (e.kind === 'dir') return keys.some(k => k.startsWith(`${rel}/`)) ? { ...e, git: '•' } : e
77 const xy = s.status.get(rel)
78 return xy ? { ...e, git: xy } : e
79 })
80}
81
82/**
83 * git status for the entries of `dir`, from one `git status` of its whole repository: the letters,
84 * and the project bar's branch and counts. Outside a repository, nothing is asked of git beyond finding that out.
85 */
86export async function gitStatusOf($: EngineInterface, root: string, dir: string, entries: Entry[]): Promise<{ entries: Entry[]; repo: RepoInfo | null }> {
87 const s = await repoStatus($, root, dir)
88 return { entries: withGit(entries, s, root), repo: s?.repo ?? null }
89}
90
91/** Refreshes the git letters and project bar of the folder on screen, and the open file's base. */
92export async function refreshGit($: EngineInterface) {
93 const ex = await getExplorer($)
94 if (ex.preview) await fetchBase($, ex.root, ex.preview.path)
95 if (ex.onlyTouched) return
96 const { entries, repo } = await gitStatusOf($, ex.root, ex.dir, ex.entries)
97 await updateExplorer($, cur => (cur.dir === ex.dir ? { ...cur, entries, repo } : cur))
98}
99
100/** Every file git knows under `root` (tracked, and untracked but not ignored), as paths from it; null outside git. */
101// ponytail: a tracked file deleted on disk (or a submodule) still lists; opening it says it cannot be read. Filter with `ls-files --deleted` if that bothers anyone.
102export async function repoFiles($: EngineInterface, root: string): Promise<string[] | null> {
103 const r = await git($, root, ['-c', 'core.quotePath=false', 'ls-files', '--cached', '--others', '--exclude-standard'])
104 if (r.exitCode !== 0) return null
105 return [...new Set(r.stdout.split('\n').filter(Boolean))]
106}
107hooks/pane.tsx 241 lines1// The pane's drawing: three boxes from the stored state, the explorer drawn by its client module.
2import type { On } from 'claude-code'
3import type { Palette } from './explorer/client'
4import { EFFORT_LEVEL, fmtDuration, fmtTokens, fmtUsd, gauge, hasLongContext, limitLabel, modeColorKey, prettyModel, sparkline } from './shared/format'
5import { bases } from './explorer/git/git'
6import { bodyProps } from './explorer/props'
7import { fitPicture } from './explorer/media/media'
8import { contextLevel, turnStats } from './session/vitals'
9import { baseName } from './shared/paths'
10import { PANE, getActivity, getAgents, getExplorer, getTouched, getView, getVitals, paneState, readGitTick, saved } from './state'
11
12export type Colors = Palette & { ok: string; bad: string; info: string; purple: string; frame: string }
13
14export const ONE_DARK: Colors = {
15 accent: '#61afef',
16 dir: '#61afef',
17 touched: '#e5c07b',
18 dim: '#5c6370',
19 text: '#abb2bf',
20 selectedBg: '#3e4451',
21 hoverBg: '#2c313a',
22 warn: '#e5c07b',
23 ok: '#98c379',
24 bad: '#e06c75',
25 info: '#56b6c2',
26 purple: '#c678dd',
27 frame: '#4b5263',
28 pillFg: '#282c34',
29}
30
31export const THEME: Colors = {
32 accent: 'claude',
33 dir: 'suggestion',
34 touched: 'warning',
35 dim: 'inactive',
36 text: 'text',
37 selectedBg: 'subtle',
38 hoverBg: 'subtle',
39 warn: 'warning',
40 ok: 'success',
41 bad: 'error',
42 info: 'suggestion',
43 purple: 'permission',
44 frame: 'inactive',
45 pillFg: 'inverseText',
46}
47
48/** Draws the pane: the session's vitals, and the tabbed body (files or agents). */
49export const registerPane = (on: On, C: Colors) => {
50 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
51 // Drawn means open: the live refresh runs from here until the pane closes.
52 paneState.isOpen = true
53 const els = $.ui.resolve(e)
54 const { Box, Text } = els
55 const hasClient = 'Client' in els
56 const [v, ex, t, list, now, act, view] = await Promise.all([
57 getVitals($),
58 getExplorer($),
59 getTouched($),
60 getAgents($),
61 $.clock.now(),
62 getActivity($),
63 getView($),
64 readGitTick($),
65 ])
66 // The pane's real width: drawing wider than it would overflow; every row below truncates instead.
67 const W = Math.max(24, e.props.bodyColumns)
68 const inner = W - 4 // a section's round border and its one-cell padding each side
69 const bodyRows = Math.max(8, e.props.scroll.bodyRows)
70
71 const clock = (key: string, since: number, color: string) =>
72 hasClient ? <els.Client key={key} module="./elapsed.tsx" props={{ since, now, color }} /> : <Text color={color}>{fmtDuration(now - since)}</Text>
73
74 const section = (title: string, right: unknown, body: unknown, color = C.frame) => (
75 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
76 <Box flexDirection="row" justifyContent="space-between">
77 <Text color={C.dim} bold>
78 {title}
79 </Text>
80 {right as never}
81 </Box>
82 {body as never}
83 </Box>
84 )
85
86 // ---- vitals
87 // The turns row: oldest turn left, the latest right; sparklines shorten on a narrow pane so both series fit.
88 const ts = turnStats(v.turns)
89 const sparkW = Math.max(3, Math.min(16, Math.floor((inner - 50) / 2)))
90 const effortN = EFFORT_LEVEL[v.effort] ?? 0
91 const pct = v.pct ?? 0
92 // From 80% the box warns, from 90% it says to compact: frame, gauge and a label beside it.
93 const level = contextLevel(v.pct)
94 const ctxColor = level === 2 ? C.bad : level === 1 ? C.warn : C.ok
95 const gaugeW = Math.max(6, Math.min(24, inner - 36))
96 const modeColor = C[modeColorKey(v.mode)]
97 const isRunning = v.turnStartedAt !== null
98 // A live timer is a Client, which only a Box may hold: inside a Text the engine refuses the whole pane.
99 const turn = isRunning ? (
100 <Box flexDirection="row" flexShrink={0}>
101 <Text color={C.ok}>● </Text>
102 {clock('turn-timer', v.turnStartedAt ?? now, C.ok)}
103 </Box>
104 ) : (
105 <Text color={C.dim}>{v.lastTurnMs === null ? '○ idle' : `○ idle · last ${fmtDuration(v.lastTurnMs)}`}</Text>
106 )
107
108 const vitalsBox = section(
109 'SESSION',
110 turn,
111 <Box flexDirection="column">
112 <Box flexDirection="row" justifyContent="space-between">
113 <Text wrap="truncate-end">
114 <Text backgroundColor={C.accent} color={C.pillFg} bold>
115 {` ${prettyModel(v.model)} `}
116 </Text>
117 {hasLongContext(v.model) ? <Text color={C.accent}> 1M</Text> : null}
118 <Text color={C.dim}>{' effort '}</Text>
119 <Text color={C.purple}>{'●'.repeat(effortN) + '○'.repeat(Math.max(0, 5 - effortN))}</Text>
120 {v.effort ? <Text color={C.text}>{` ${v.effort}`}</Text> : <Text color={C.dim}> after 1st reply</Text>}
121 </Text>
122 <Text color={modeColor} bold>
123 {v.mode}
124 </Text>
125 </Box>
126 <Box flexDirection="row" justifyContent="space-between">
127 <Text wrap="truncate-end">
128 <Text color={C.dim}>{'ctx '}</Text>
129 <Text color={ctxColor}>{gauge(pct, gaugeW)}</Text>
130 {v.pct === null ? (
131 <Text color={C.dim}> waiting for the 1st reply</Text>
132 ) : (
133 <Text color={C.text} bold>
134 {` ${Math.round(pct)}%`}
135 </Text>
136 )}
137 {v.tokens !== null && v.window ? <Text color={C.dim}>{` ${fmtTokens(v.tokens)} / ${fmtTokens(v.window)}`}</Text> : null}
138 {v.compactions > 0 ? <Text color={C.dim}>{` ⟲${v.compactions}`}</Text> : null}
139 {level > 0 ? (
140 <Text color={ctxColor} bold>
141 {level === 2 ? ' ⚠ compact soon' : ' ⚠ filling up'}
142 </Text>
143 ) : null}
144 </Text>
145 <Text>
146 <Text color={C.ok} bold>
147 {v.costUsd === null ? '$—' : fmtUsd(v.costUsd)}
148 </Text>
149 {v.lastTurnCost !== null && v.lastTurnCost > 0.005 ? <Text color={C.dim}>{` +${fmtUsd(v.lastTurnCost)}`}</Text> : null}
150 </Text>
151 </Box>
152 {v.limits.length > 0 ? (
153 <Text wrap="truncate-end">
154 <Text color={C.dim}>{'lim '}</Text>
155 {v.limits.slice(0, 3).map(l => (
156 <Text>
157 <Text color={C.dim}>{`${limitLabel(l.kind)} `}</Text>
158 <Text color={l.pct >= 85 ? C.bad : l.pct >= 60 ? C.warn : C.ok}>{gauge(l.pct, 6)}</Text>
159 <Text color={C.text}>{` ${Math.round(l.pct)}% `}</Text>
160 </Text>
161 ))}
162 </Text>
163 ) : null}
164 {v.showTurns && ts.count === 0 ? (
165 <Text color={C.dim} wrap="truncate-end">
166 {'turns none finished yet'}
167 </Text>
168 ) : null}
169 {v.showTurns && ts.count > 0 ? (
170 <Text wrap="truncate-end">
171 <Text color={C.dim}>{`${ts.count} ${ts.count === 1 ? 'turn ' : 'turns'} `}</Text>
172 {ts.costs ? (
173 <Text>
174 <Text color={C.dim}>{'cost '}</Text>
175 <Text color={C.ok}>{sparkline(ts.costs.slice(-sparkW))}</Text>
176 <Text color={C.text}>{` ${ts.lastCost === null ? '—' : fmtUsd(ts.lastCost)}`}</Text>
177 <Text color={C.dim}>{` (avg ${fmtUsd(ts.avgCost ?? 0)}) `}</Text>
178 </Text>
179 ) : null}
180 <Text color={C.dim}>{'time '}</Text>
181 <Text color={C.accent}>{sparkline(ts.times.slice(-sparkW))}</Text>
182 <Text color={C.text}>{` ${fmtDuration(ts.lastMs)}`}</Text>
183 <Text color={C.dim}>{` (avg ${fmtDuration(ts.avgMs)})`}</Text>
184 </Text>
185 ) : null}
186 </Box>,
187 level === 2 ? C.bad : level === 1 ? C.warn : C.frame,
188 )
189 const vitalsRows = 2 + 1 + 2 + (v.limits.length > 0 ? 1 : 0) + (v.showTurns ? 1 : 0)
190
191 // ---- the body: the tab strip, the box's border, breadcrumb and rule, the rows, the hint line
192 const room = Math.max(3, bodyRows - vitalsRows - 7)
193 const props = bodyProps({
194 view,
195 agents: list,
196 ex,
197 touched: t,
198 activity: act,
199 now,
200 room,
201 width: inner,
202 colors: C,
203 base: ex.preview ? (bases.get(ex.preview.path) ?? null) : null,
204 disk: ex.preview ? (saved.get(ex.preview.path) ?? null) : null,
205 })
206
207 // A picture is the pane's to draw (the explorer client cannot): over the explorer's empty body.
208 const pic = props.body === 'files' && ex.preview?.kind === 'image' ? (ex.preview.image ?? null) : null
209 const picRows = Math.max(1, room - (props.note ? 1 : 0))
210 const picBox = pic ? fitPicture(pic.width, pic.height, inner, picRows) : null
211 const picture =
212 pic && picBox && 'Image' in els ? (
213 <Box position="absolute" top={5 + (props.note ? 1 : 0)} left={2 + Math.floor((inner - picBox.columns) / 2)}>
214 <els.Image key="picture" source={{ file: pic.file, format: 'png' }} columns={picBox.columns} rows={picBox.rows} alt={baseName(pic.file)} />
215 </Box>
216 ) : null
217
218 // The client draws the box itself, so the tab strip can sit on its top edge.
219 const frame = props.body === 'agents' ? C.info : props.body !== 'files' ? C.frame : ex.preview?.mode === 'edit' ? C.warn : ex.preview ? C.accent : C.frame
220 const filesBox = (
221 <Box flexDirection="column" width={W}>
222 {hasClient ? (
223 <els.Client key="explorer" module="./explorer/client.tsx" props={{ ...props, frame }} width={W} height={room + 7} />
224 ) : (
225 <Box borderStyle="round" borderColor={C.frame} paddingX={1} width={W}>
226 <Text color={C.dim}>The file explorer needs a surface that runs client modules (the terminal does).</Text>
227 </Box>
228 )}
229 {picture}
230 </Box>
231 )
232
233 return (
234 <Box flexDirection="column" width={W}>
235 {vitalsBox}
236 {filesBox}
237 </Box>
238 )
239 })
240}
241