SLOPSHOPPER

claudashboard

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

newpaneguardcommandtoaststatus
★ 4v0.5.0MITupdated 2026-10-15strackzdev/Claudashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudashboard
│ ┃ CLAUDASHBOARD ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ SESSION ○ idle · ⏺ Read(src/auth.ts) │ ┃ │ opus 5.5 effort ○○○○○ after 1st reply ⎿ Read 6 lines │ ┃ │ ctx ▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱ 49% 97k / 200k ⏺ Update(src/auth.ts) │ ┃ │ lim 5h ▰▰▱▱▱▱ 31% ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ▣ client module ./explorer/client.tsx ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /claudashboard │ ⎿ claudashboard: Claudashboard open. Click the file list to give i │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CLAUDASHBOARD
╭──────────────────────────────────────────────────────╮ │ SESSION ○ idle · last 0:00 │ │ opus 5.5 effort ○○○○○ after 1st reply default │ │ ctx ▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱ 49% 97k / 200k $0.42 │ │ lim 5h ▰▰▱▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ ▣ client module ./explorer/client.tsx
README

Claudashboard

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.

Claudashboard's files tab: the session box with the turns row, the project bar beside the tabs, and the explorer listing a git repository with its changed files marked

Every number comes from real session events. Nothing leaves your machine.

Install

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

What you see

Session

ShowsNotes
ModelThe main loop's model. 1M means the long-context variant. It's read when the session starts and updated on every step.
EffortThe current turn's reasoning effort, from low to max.
Permission modedefault, 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.
ContextA 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.
CostThe session total, plus what the last turn added.
LimitsThe 5-hour and 7-day rate-limit windows, when your plan reports them.
TurnsOff 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.

Tabs

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.

Agents

Claudashboard's agents tab: two subagents running with their task, current tool and timer, and one finished agent in the history

Running: each agent gets a card showing:

  • its name, type and model
  • steps, tool calls, the context it's using and a live timer
  • its task (↳)
  • the tool it's running right now (⟶), such as Read middleware.ts or $ npm test

Finished: 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.

Files

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.

KeyIn the listIn the preview
↑ ↓ / k jmovescroll
PgUp PgDnpagepage (Space too)
Home Endfirst / lasttop / end
g G—top / end
⏎ → lopen—
⌫ ← hparent folder (clears a filter first)back to the list (q too)
/filter by name as you type (⏎ or Tab to finish)—
f / ctrl+pfind a file anywhere (see below)find a file anywhere
.show / hide dotfiles—
m—Markdown: rendered ↔ source
tonly touched files: in a git repository, what git status reports changed; elsewhere, what Claude edited this session—
rrefreshrefresh 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 promptinsert @path (with the picked lines, if any) into your prompt
eopen the file straight into edit modeswitch to edit mode
Tabagents tabagents tab
?helphelp

Finding a file

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.

Editing a file

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 modeDoes
typing, Space, Tabinsert 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
⌫ Deldelete before / after the cursor, joining lines at the edges
↑ ↓ ← →move. Left and right wrap between lines.
Home Endstart of the line's indentation (press again for column 1) / end of line
PgUp PgDn, ctrl+Home ctrl+Endpage / start and end of the file
clickput the cursor there
ctrl+ssave (or run /claudashboard save)
ctrl+zundo, up to 100 steps per file
ctrl+eback 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:

  • Exact saves: a save writes the file in the shape it was read, keeping its line endings (LF or CRLF) and whether it ends with a newline.
  • Read-only files: editing is off for any file the preview had to alter to show it, so a save can never write the altered text. That means binaries, files over 1 MB or 5,000 lines, files with control characters, minified lines over 10,000 characters, or mixed line endings. The label then reads READ ONLY, and the reason shows on the line above the code.
  • Changes made elsewhere: if the file changed on disk after you opened it, for example because Claude edited it, ctrl+s warns first and only a second ctrl+s overwrites it. Without unsaved edits, the open file simply reloads when Claude changes it.

Markdown and pictures

  • Markdown files (.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.
  • PNG pictures show as real pixels in terminals that can draw them (Ghostty, kitty), at their own proportions. Other terminals show the file name in their place. The size in pixels shows at the bottom right.
  • JPEG, GIF, WebP and BMP are converted to PNG on the fly if ImageMagick or ffmpeg is installed (sudo apt install imagemagick). Without either, the pane tells you what to install.

Git changes

Every line of an open file has a gutter mark, in view mode and edit mode, layered the way git sees the file:

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

Commands

CommandDoes
/claudashboardopen the hub
/claudashboard closeclose it
/claudashboard files, /claudashboard agentsshow that tab
/claudashboard savesave 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 resetclear 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.

Configure

Open /config and find claudashboard, or set the values under pluginConfigs.claudashboard in your settings.

OptionDefault
paletteonedarkonedark uses fixed One Dark colours. theme follows your Claude Code theme.
showHiddenfalseShow dotfiles in the explorer by default.
openOnStarttrueOpen the pane when a session starts.

Layout tips

  • In fullscreen at 110 columns or wider, the pane docks beside the transcript. Otherwise it sits inline above the prompt, about 30 rows tall.
  • Every row fits the pane's width: the file path folds its middle folders (strackz › … › CODEOWNERS) and then shortens the name, the mode label switches to VIEW / EDIT / RO, and the line count sits at the bottom right.
  • When it gets cramped, close other panes. /claudashboard close hides Claudashboard until you run /claudashboard again.

Develop

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.

License

MIT © strackzdev

Source 25 files
hooks/register.tsx 433 lines
1// 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}
433
hooks/session/vitals.ts 62 lines
1// 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}
62
hooks/agents/agents.ts 71 lines
1// 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}
71
hooks/agents/view.ts 53 lines
1// 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}
53
hooks/help.ts 120 lines
1// 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}
120
hooks/explorer/finder.ts 85 lines
1// 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}
85
hooks/explorer/editor/editing.ts 99 lines
1// 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}
99
hooks/explorer/client.tsx 557 lines
1// 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
557
hooks/explorer/model.ts 241 lines
1// 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}
241
hooks/explorer/files.ts 228 lines
1// 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}
228
hooks/explorer/git/git.ts 107 lines
1// 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}
107
hooks/pane.tsx 241 lines
1// 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