Docked pane showing the project, branch, upstream, worktrees, recent commits, the last turn's recap and the session's context fill.

A Claude Code mod (function-hooks plugin) that docks a pane beside the transcript with the facts a developer keeps checking by hand:
owner/name of originok when the upstream name is the branch's own, mismatch when it points elsewhere (the state that makes git push refuse under push.default=simple), gone when the remote branch was deleted, none when unsetThe pane opens by itself when the terminal runs the fullscreen layout and is at least 144 columns wide. /dashboard opens or closes it at any width. r refreshes, h hides.
Colors follow Claude Code's theme setting, a custom theme counting as the preset it is based on: a light theme gets PhpStorm's Rider Melon tool-window gray with a keyword-blue accent, a dark theme the Catppuccin Mocha mantle with a mauve accent, each matching the Ghostty theme of that mode. The theme option forces one.
For a session started from a terminal, name the folder in CLAUDE_CODE_PLUGIN_DIRS:
export CLAUDE_CODE_PLUGIN_DIRS="$HOME/.claude/mods/dev-dashboard"
For sessions the desktop app starts, put the same key in the env block of ~/.claude/settings.json. Either way the folder is watched: a saved edit reloads the module.
Options live under /config (or pluginConfigs.dev-dashboard in settings): banner, commits, autoOpen, pollSeconds, recapLines, theme.
art: flower draws a six-petal flower (14 by 14 pixels, two per cell with ▀) on the terminal surface, beside the banner and project name when the pane is wide enough for both and above them otherwise; its black surround takes the pane's background color. The pixels live in hooks/flower.ts, generated from a photo with pure-Python PNG decoding and box downsampling.
The banner is drawn with a built-in 3-row block font (letters, digits, -, .): 3 by 5 pixel glyphs, two cell columns per pixel so the stems are a full cell wide. Where that form does not fit, the glyphs fall back to one column per pixel; a text still wider than the pane is left out rather than cut.
claude plugin validate ~/.claude/mods/dev-dashboard
tsc -p ~/.claude/mods/dev-dashboard # after the engine has loaded it once
claude plugin test ~/.claude/mods/dev-dashboardhooks/register.tsx 421 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { GitSnapshot, SessionFacts, TurnRecap, UpstreamStatus } from '../types'
5import { bannerWidth, renderBanner } from './banner'
6import { FLOWER } from './flower'
7import { readGitSnapshot } from './git'
8import { PALETTES, customThemeSlug, resolveTheme } from './palette'
9import type { Palette, ThemeName } from './palette'
10import { makeHalfBlockCells } from './raster'
11import { RECAP_ROOTS_KEY, formatDuration, formatTokens, makeRecap, recapKey, relativeAge, rememberRoot } from './recap'
12
13const PANE_ID = 'dev-dashboard'
14const PANE_TITLE = 'Dashboard'
15const PANE_COLUMNS = 46
16const GIT_TIMEOUT_MS = 10000
17const LABEL_WIDTH = 10
18
19const git = atom({ plugin: 'dev-dashboard', key: 'git' } as const, null)
20const lastTurn = atom({ plugin: 'dev-dashboard', key: 'lastTurn' } as const, null)
21const previous = atom({ plugin: 'dev-dashboard', key: 'previous' } as const, null)
22const session = atom({ plugin: 'dev-dashboard', key: 'session' } as const, null)
23const isDismissed = atom({ plugin: 'dev-dashboard', key: 'isDismissed' } as const, false)
24
25const UPSTREAM_NOTE: Record<UpstreamStatus, string | null> = {
26 ok: null,
27 mismatch: '! name differs, git push is refused',
28 gone: '! remote branch was deleted',
29 none: 'no upstream, first push sets it',
30}
31
32const gitRefresh: { pending: Promise<GitSnapshot> | null; isQueued: boolean } = { pending: null, isQueued: false }
33
34function fit(text: string, width: number): string {
35 if (width <= 1) return ''
36
37 return text.length > width ? `${text.slice(0, width - 1)}…` : text
38}
39
40function toNumber(value: unknown, fallback: number): number {
41 const parsed = typeof value === 'number' ? value : Number(value)
42
43 return Number.isFinite(parsed) ? parsed : fallback
44}
45
46function upstreamColor(palette: Palette, status: UpstreamStatus): string {
47 if (status === 'ok') return palette.ok
48 if (status === 'mismatch') return palette.error
49 if (status === 'gone') return palette.warning
50
51 return palette.muted
52}
53
54function refreshGit($: EngineInterface, commitCount: number): Promise<GitSnapshot> {
55 if (gitRefresh.pending !== null) {
56 gitRefresh.isQueued = true
57
58 return gitRefresh.pending
59 }
60
61 gitRefresh.pending = (async () => {
62 const cwd = await $.session.cwd()
63 const run = async (args: readonly string[]) => {
64 const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS })
65
66 return { exitCode: ran.exitCode, stdout: ran.stdout }
67 }
68 const snapshot = await readGitSnapshot(run, cwd, commitCount, await $.clock.now())
69 await update($, git, () => snapshot)
70
71 return snapshot
72 })().finally(() => {
73 gitRefresh.pending = null
74
75 if (gitRefresh.isQueued) {
76 gitRefresh.isQueued = false
77 void refreshGit($, commitCount)
78 }
79 })
80
81 return gitRefresh.pending
82}
83
84async function refreshSession($: EngineInterface): Promise<void> {
85 const [model, turns, usage] = await Promise.all([$.session.model(), $.session.turns(), $.session.usage()])
86 const facts: SessionFacts = { model, turns, contextPercent: usage.context.percent ?? null }
87
88 await update($, session, () => facts)
89}
90
91async function loadPrevious($: EngineInterface, repoRoot: string | null): Promise<void> {
92 if (repoRoot === null) return
93
94 const stored = await $.store.get(recapKey(repoRoot))
95 const recap = stored !== undefined && stored !== null && typeof stored === 'object' ? (stored as TurnRecap) : null
96
97 await update($, previous, () => recap)
98}
99
100async function persistRecap($: EngineInterface, recap: TurnRecap): Promise<void> {
101 if (recap.repoRoot === null) return
102
103 const roots = await $.store.get(RECAP_ROOTS_KEY)
104 const known = Array.isArray(roots) ? roots.filter((root): root is string => typeof root === 'string') : []
105 const kept = rememberRoot(known, recap.repoRoot)
106
107 await Promise.all(known.filter(root => !kept.includes(root)).map(root => $.store.delete(recapKey(root))))
108 await $.store.set(recapKey(recap.repoRoot), recap)
109 await $.store.set(RECAP_ROOTS_KEY, kept)
110}
111
112async function customThemeBase($: EngineInterface, slug: string): Promise<unknown> {
113 try {
114 const home = await $.env.get('HOME')
115 const theme = JSON.parse(await $.fs.read(`${home}/.claude/themes/${slug}.json`)) as { base?: unknown }
116
117 return theme.base ?? 'dark'
118 } catch {
119 return 'dark'
120 }
121}
122
123async function readTheme($: EngineInterface, option: unknown): Promise<ThemeName> {
124 const settings = await $.settings.read()
125 const slug = customThemeSlug(settings.theme)
126
127 return resolveTheme(option, slug === null ? settings.theme : await customThemeBase($, slug))
128}
129
130function openPane($: EngineInterface) {
131 return $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
132}
133
134export const register: Register = (on, options) => {
135 const commitCount = Math.max(1, Math.floor(toNumber(options.commits, 8)))
136 const recapLines = Math.max(1, Math.floor(toNumber(options.recapLines, 4)))
137 const pollSeconds = Math.max(0, toNumber(options.pollSeconds, 30))
138 const autoOpen = options.autoOpen !== false
139 const bannerText = typeof options.banner === 'string' ? options.banner : ''
140 const wideBanner = renderBanner(bannerText, 2)
141 const narrowBanner = renderBanner(bannerText, 1)
142 const bannerFor = (columns: number) => [wideBanner, narrowBanner].find(rows => rows.length > 0 && bannerWidth(rows) <= columns) ?? null
143 const artwork = options.art === 'none' ? null : FLOWER
144
145 let theme: ThemeName = 'dark'
146 let turnStartedAt = 0
147 let toolCalls = 0
148 let hasAutoOpened = false
149
150 on('session.start', async ($, e, next) => {
151 await $.command.register({ name: 'dashboard', description: 'Toggle the dev dashboard pane' })
152
153 theme = await readTheme($, options.theme)
154 const snapshot = await refreshGit($, commitCount)
155 await Promise.all([loadPrevious($, snapshot.mainRoot), refreshSession($)])
156
157 if (pollSeconds > 0) {
158 $.clock.every(pollSeconds * 1000, () => void refreshGit($, commitCount))
159 }
160
161 return next(e)
162 })
163
164 on('command.run', { command: 'dashboard' }, async $ => {
165 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
166
167 if (isOpen) {
168 await $.ui.close({ id: PANE_ID })
169
170 return { text: 'Dashboard closed.' }
171 }
172
173 await update($, isDismissed, () => false)
174 const opened = await openPane($)
175
176 return { text: opened.isPlaced ? 'Dashboard opened.' : `Dashboard waits: ${opened.reason}` }
177 })
178
179 on('ui.close', async ($, e, next) => {
180 if (e.id === PANE_ID && e.origin.kind === 'person') {
181 await update($, isDismissed, () => true)
182 }
183
184 return next(e)
185 })
186
187 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
188 if (autoOpen && !hasAutoOpened && e.viewport?.isFullscreen === true && !(await read($, isDismissed))) {
189 hasAutoOpened = true
190 void openPane($)
191 }
192
193 return next(e)
194 })
195
196 on('prompt.submit', async ($, e, next) => {
197 turnStartedAt = await $.clock.now()
198 toolCalls = 0
199
200 return next(e)
201 })
202
203 on('tool.call', async ($, e, next) => {
204 const isMainLoop = e.agentId === undefined
205 if (isMainLoop) toolCalls += 1
206
207 const ran = await next(e)
208
209 if (isMainLoop && ran.deny === undefined && ran.isReadOnly !== true) {
210 void refreshGit($, commitCount)
211 }
212
213 return ran
214 })
215
216 on('turn.complete', async ($, e, next) => {
217 if (e.agentId !== undefined) return next(e)
218
219 const endedAt = await $.clock.now()
220 const snapshot = await read($, git)
221 const recap = makeRecap({
222 answer: e.answer,
223 durationMs: turnStartedAt > 0 ? endedAt - turnStartedAt : e.durationMs,
224 toolCalls,
225 outputTokens: e.usage?.output_tokens ?? null,
226 reason: e.reason,
227 repoRoot: snapshot?.mainRoot ?? null,
228 endedAt,
229 lineCount: recapLines,
230 })
231
232 await update($, lastTurn, () => recap)
233 await Promise.all([persistRecap($, recap), refreshSession($), refreshGit($, commitCount)])
234
235 return next(e)
236 })
237
238 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
239 const { Box, Button, Text } = $.ui.resolve(e)
240 const palette = PALETTES[theme]
241 const width = Math.max(10, e.props.bodyColumns - 2)
242 const now = await $.clock.now()
243 const [snapshot, turn, prior, facts] = await Promise.all([read($, git), read($, lastTurn), read($, previous), read($, session)])
244
245 const heading = (label: string) => (
246 <Box key={`h:${label}`} marginTop={1}>
247 <Text color={palette.accent} bold>
248 {fit(`── ${label} `.padEnd(width, '─'), width)}
249 </Text>
250 </Box>
251 )
252 const row = (label: string, value: string, color: string = palette.text) => (
253 <Box key={`r:${label}`}>
254 <Text color={palette.muted}>{label.padEnd(LABEL_WIDTH)}</Text>
255 <Text color={color} wrap="truncate-end">
256 {fit(value, width - LABEL_WIDTH)}
257 </Text>
258 </Box>
259 )
260 const recapBlock = (label: string, recap: TurnRecap | null) => {
261 if (recap === null) return null
262
263 const summary = [
264 relativeAge(recap.endedAt, now),
265 formatDuration(recap.durationMs),
266 `${recap.toolCalls} tools`,
267 recap.outputTokens === null ? null : `${formatTokens(recap.outputTokens)} out`,
268 recap.reason === 'answer' ? null : recap.reason,
269 ].filter((fact): fact is string => fact !== null)
270
271 return (
272 <Box key={`recap:${label}`} flexDirection="column">
273 {heading(label)}
274 <Text color={palette.muted}>{fit(summary.join(' · '), width)}</Text>
275 {recap.lines.map((line, index) => (
276 <Text key={`${label}:${index}`} color={palette.text} wrap="wrap">
277 {line}
278 </Text>
279 ))}
280 </Box>
281 )
282 }
283
284 const artworkCells = artwork !== null && e.surface === 'terminal' && artwork.width <= width ? makeHalfBlockCells(artwork, palette.background) : null
285 const art = () => {
286 if (artworkCells === null || e.surface !== 'terminal') return null
287
288 const { Raster } = $.ui.resolve(e)
289
290 return <Raster key="flower" columns={artworkCells.columns} rows={artworkCells.rows} cells={artworkCells.cells} />
291 }
292 const title = (snapshot: GitSnapshot, titleWidth: number) => {
293 const banner = bannerFor(titleWidth)
294
295 return (
296 <Box flexDirection="column">
297 {banner !== null && (
298 <Box flexDirection="column" marginBottom={1}>
299 {banner.map((bannerRow, index) => (
300 <Text key={`banner:${index}`} color={palette.accent} bold>
301 {bannerRow}
302 </Text>
303 ))}
304 </Box>
305 )}
306 <Text color={palette.accent} bold>
307 {fit(snapshot.projectName, titleWidth)}
308 </Text>
309 {snapshot.remoteName !== null && <Text color={palette.muted}>{fit(snapshot.remoteName, titleWidth)}</Text>}
310 </Box>
311 )
312 }
313 const header = (snapshot: GitSnapshot) => {
314 const artColumns = artworkCells === null ? 0 : artworkCells.columns + 2
315 const isSideBySide = artworkCells !== null && artColumns + Math.max(bannerWidth(narrowBanner), snapshot.projectName.length) <= width
316
317 if (isSideBySide) {
318 return (
319 <Box alignItems="center">
320 <Box marginRight={2}>{art()}</Box>
321 {title(snapshot, width - artColumns)}
322 </Box>
323 )
324 }
325
326 return (
327 <Box flexDirection="column">
328 {artworkCells !== null && <Box marginBottom={1}>{art()}</Box>}
329 {title(snapshot, width)}
330 </Box>
331 )
332 }
333 const frame = (children: RenderChildren) => (
334 <Box flexDirection="column" width="100%" minHeight={Math.max(1, e.props.scroll.bodyRows)} paddingX={1} backgroundColor={palette.background}>
335 {children}
336 </Box>
337 )
338
339 if (snapshot === null) {
340 return frame(<Text color={palette.muted}>Reading the repository…</Text>)
341 }
342
343 const upstreamNote = UPSTREAM_NOTE[snapshot.upstreamStatus]
344 const otherWorktrees = snapshot.worktrees.filter(worktree => !worktree.isCurrent)
345 const sessionLine = facts === null
346 ? null
347 : [facts.model, `${facts.turns} turns`, facts.contextPercent === null ? null : `ctx ${Math.round(facts.contextPercent)}%`]
348 .filter((fact): fact is string => fact !== null)
349 .join(' · ')
350
351 return frame(
352 <Box flexDirection="column">
353 {header(snapshot)}
354 {sessionLine !== null && (
355 <Box flexDirection="column">
356 {heading('Session')}
357 <Text color={palette.muted}>{fit(sessionLine, width)}</Text>
358 </Box>
359 )}
360 {!snapshot.isRepo && <Text color={palette.muted}>not a git repository</Text>}
361 {snapshot.isRepo && (
362 <Box flexDirection="column">
363 {heading('Git')}
364 {row('branch', snapshot.branch ?? 'HEAD (detached)', snapshot.branch === null ? palette.warning : palette.text)}
365 {snapshot.worktree !== null && row('worktree', snapshot.worktree)}
366 {row('upstream', snapshot.upstream ?? 'none', upstreamColor(palette, snapshot.upstreamStatus))}
367 {upstreamNote !== null && (
368 <Text color={upstreamColor(palette, snapshot.upstreamStatus)} wrap="truncate-end">
369 {fit(` ${upstreamNote}`, width)}
370 </Text>
371 )}
372 {snapshot.pushTarget !== null && row('push', snapshot.pushTarget, palette.warning)}
373 {row(
374 'sync',
375 `↑${snapshot.ahead} ↓${snapshot.behind} ${snapshot.dirty === 0 ? 'clean' : `${snapshot.dirty} changed`}`,
376 snapshot.dirty === 0 && snapshot.ahead === 0 && snapshot.behind === 0 ? palette.ok : palette.text,
377 )}
378 {otherWorktrees.length > 0 && (
379 <Box flexDirection="column">
380 {heading(`Worktrees (${otherWorktrees.length})`)}
381 {otherWorktrees.map(worktree => (
382 <Box key={`w:${worktree.path}`} flexDirection="column">
383 <Text color={palette.text} wrap="truncate-end">
384 {fit(`${worktree.name}${worktree.branch === null ? ' (detached)' : ''}${worktree.status === 'ok' ? '' : ` (${worktree.status})`}`, width)}
385 </Text>
386 <Text color={upstreamColor(palette, worktree.status)} wrap="truncate-end">
387 {fit(` → ${worktree.upstream ?? 'none'}`, width)}
388 </Text>
389 </Box>
390 ))}
391 </Box>
392 )}
393 </Box>
394 )}
395 {recapBlock('Last turn', turn)}
396 {prior !== null && (turn === null || prior.endedAt !== turn.endedAt) && recapBlock('Previous session', prior)}
397 {snapshot.isRepo && (
398 <Box flexDirection="column">
399 {heading('Commits')}
400 {snapshot.commits.length === 0 && <Text color={palette.muted}>no commits yet</Text>}
401 {snapshot.commits.map(commit => (
402 <Box key={`c:${commit.hash}`}>
403 <Text color={palette.hash}>{commit.hash.slice(0, 8)} </Text>
404 <Text color={palette.muted}>{commit.age.replace(/ ago$/, '').replace(/ (\w)\w+$/, '$1').padEnd(3)} </Text>
405 <Text color={palette.text} wrap="truncate-end">
406 {fit(commit.subject, width - 13)}
407 </Text>
408 </Box>
409 ))}
410 </Box>
411 )}
412 <Box marginTop={1}>
413 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refreshGit($, commitCount)} />
414 <Text> </Text>
415 <Button key="hide" label="Hide" hotkey="h" role="dismiss" onPress={() => $.ui.close({ id: PANE_ID })} />
416 </Box>
417 </Box>,
418 )
419 })
420}
421hooks/banner.ts 91 lines1export const BANNER_ROWS = 3
2
3// Each glyph is 3 by 5 pixels ('#' is ink). Drawn two cell columns per pixel,
4// every stem is a full cell wide, which is what keeps the letters readable at
5// small font sizes; one column per pixel is the fallback for panes too narrow
6// for that. Pixel rows pair up into cells with the upper and lower half
7// blocks, which leaves one blank pixel row under the letters.
8const PIXEL_GLYPHS: Record<string, readonly string[]> = {
9 A: ['.#.', '#.#', '###', '#.#', '#.#'],
10 B: ['##.', '#.#', '##.', '#.#', '##.'],
11 C: ['.##', '#..', '#..', '#..', '.##'],
12 D: ['##.', '#.#', '#.#', '#.#', '##.'],
13 E: ['###', '#..', '##.', '#..', '###'],
14 F: ['###', '#..', '##.', '#..', '#..'],
15 G: ['.##', '#..', '#.#', '#.#', '.##'],
16 H: ['#.#', '#.#', '###', '#.#', '#.#'],
17 I: ['###', '.#.', '.#.', '.#.', '###'],
18 J: ['..#', '..#', '..#', '#.#', '.#.'],
19 K: ['#.#', '#.#', '##.', '#.#', '#.#'],
20 L: ['#..', '#..', '#..', '#..', '###'],
21 M: ['#.#', '###', '###', '#.#', '#.#'],
22 N: ['##.', '#.#', '#.#', '#.#', '#.#'],
23 O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
24 P: ['##.', '#.#', '##.', '#..', '#..'],
25 Q: ['.#.', '#.#', '#.#', '.#.', '..#'],
26 R: ['##.', '#.#', '##.', '#.#', '#.#'],
27 S: ['.##', '#..', '.#.', '..#', '##.'],
28 T: ['###', '.#.', '.#.', '.#.', '.#.'],
29 U: ['#.#', '#.#', '#.#', '#.#', '###'],
30 V: ['#.#', '#.#', '#.#', '#.#', '.#.'],
31 W: ['#.#', '#.#', '###', '###', '#.#'],
32 X: ['#.#', '#.#', '.#.', '#.#', '#.#'],
33 Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
34 Z: ['###', '..#', '.#.', '#..', '###'],
35 '0': ['###', '#.#', '#.#', '#.#', '###'],
36 '1': ['.#.', '##.', '.#.', '.#.', '###'],
37 '2': ['##.', '..#', '.#.', '#..', '###'],
38 '3': ['###', '..#', '.##', '..#', '###'],
39 '4': ['#.#', '#.#', '###', '..#', '..#'],
40 '5': ['###', '#..', '##.', '..#', '##.'],
41 '6': ['.##', '#..', '###', '#.#', '###'],
42 '7': ['###', '..#', '.#.', '.#.', '.#.'],
43 '8': ['###', '#.#', '###', '#.#', '###'],
44 '9': ['###', '#.#', '###', '..#', '##.'],
45 '-': ['...', '...', '###', '...', '...'],
46 '.': ['...', '...', '...', '...', '.#.'],
47 ' ': ['..', '..', '..', '..', '..'],
48}
49
50export type ColumnsPerPixel = 1 | 2
51
52function glyphCells(pixelRows: readonly string[], columnsPerPixel: ColumnsPerPixel): string[] {
53 const width = (pixelRows[0]?.length ?? 0) * columnsPerPixel
54 const cells: string[] = []
55
56 for (let row = 0; row < BANNER_ROWS; row += 1) {
57 let line = ''
58
59 for (let column = 0; column < width; column += 1) {
60 const pixelColumn = Math.floor(column / columnsPerPixel)
61 const upper = pixelRows[row * 2]?.[pixelColumn] === '#'
62 const lower = pixelRows[row * 2 + 1]?.[pixelColumn] === '#'
63
64 line += upper && lower ? '█' : upper ? '▀' : lower ? '▄' : ' '
65 }
66
67 cells.push(line)
68 }
69
70 return cells
71}
72
73export function renderBanner(text: string, columnsPerPixel: ColumnsPerPixel = 2): string[] {
74 const rows: string[] = ['', '', '']
75
76 for (const char of text.toUpperCase()) {
77 const glyph = PIXEL_GLYPHS[char]
78 if (glyph === undefined) continue
79
80 glyphCells(glyph, columnsPerPixel).forEach((part, index) => {
81 rows[index] = rows[index] === '' ? part : `${rows[index]} ${part}`
82 })
83 }
84
85 return rows[0] === '' ? [] : rows
86}
87
88export function bannerWidth(rows: readonly string[]): number {
89 return rows.reduce((widest, row) => Math.max(widest, row.length), 0)
90}
91hooks/flower.ts 23 lines1import type { PixelArt } from './raster'
2
3export const FLOWER: PixelArt = {
4 width: 14,
5 height: 14,
6 pixels: [
7 '000000000000000000000000000000869898c8d3d398a3a26a7c7b000000000000000000000000000000',
8 '000000000000000000000000476060e4e7e7eae8e9c7c7c594a4a30000000000005c6f6f000000000000',
9 '00000093b4b6d9dbdbc6cac96a7d7ee1e7e8e7e6e6c8cac9c8d0cf7f8f90c6d1d2d1d3d2c0c8c9000000',
10 '738989b4bcbbdfdedde2e1e1c2c5c3c0cbcae0dfdec2c1beaeb3b1c6cccbeae9e9ecebebdee4e5608083',
11 '647c7ed8d8d8e4e3e3e7e6e6d4d3d1b1bab8bec6c6b9bbb9bab9b6a9b3b0e3e1e1e8e7e7d2d8d8000000',
12 '000000cbd6d7f0f0f0eeededd9d7d68aa3a44ca7b159aeb897b4b5b4bab8dedcdcd9d9d99aabac000000',
13 '0000005b7173a7bcbec5d2d3d3d9d85ea0a722c2da21c3db56aab4b9c7c6d4dfdfd6dedf808e90000000',
14 'b3c7c9cdd1d1cfcdcbcccbc9b2b9b74f8c921e9fb121b7cc519aa2c4c7c6e1e0dfe8e7e7dcdbda798484',
15 'bdced0dbd9d9e1e0dfe9e8e8e8e8e79bacad43828846888e90a8a8cbcac8d8d6d5dbd9d9dad9d8bbc7c7',
16 '7e9596cbcac9e2e1e1eeeeeecfdadb9ab6b8bfc0beb8b8b6b4bcbab8c8c8e0e0dfd8d6d5d1d0cfa5b1b1',
17 '607273a0a7a6d8d9d8c7cfcf87a1a4cbd3d4e4e3e3dad8d8d5d4d36d898bcfd6d6dbe0df9eadac678182',
18 '00000000000058686a000000000000cad9dbedecece1dfe0cfcfcd5d7a7c000000000000000000000000',
19 '00000000000000000000000000000086a0a3bcd4d7d4d4d39ba4a3246870000000000000000000000000',
20 '000000000000000000000000000000000000000000809597466f73000000000000000000000000000000',
21 ].join(''),
22}
23hooks/git.ts 168 lines1import type { Commit, GitSnapshot, UpstreamStatus, WorktreeRow } from '../types'
2
3export type GitRun = (args: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
4
5type RefRow = {
6 branch: string
7 upstream: string | null
8 remoteRef: string
9 track: string
10 push: string | null
11}
12
13export function basename(path: string): string {
14 const trimmed = path.replace(/\/+$/, '')
15
16 return trimmed.slice(trimmed.lastIndexOf('/') + 1)
17}
18
19export function parseRemoteName(remoteUrl: string | null): string | null {
20 if (remoteUrl === null || remoteUrl === '') return null
21
22 const match = /([^/:]+\/[^/:]+?)(?:\.git)?\/?$/.exec(remoteUrl)
23
24 return match?.[1] ?? null
25}
26
27export function judgeUpstream(branch: string | null, remoteRef: string, track: string): UpstreamStatus {
28 if (branch === null || remoteRef === '') return 'none'
29 if (track.includes('gone')) return 'gone'
30
31 return remoteRef === `refs/heads/${branch}` ? 'ok' : 'mismatch'
32}
33
34export function parseTrack(track: string): { ahead: number; behind: number } {
35 const ahead = /ahead (\d+)/.exec(track)
36 const behind = /behind (\d+)/.exec(track)
37
38 return { ahead: Number(ahead?.[1] ?? 0), behind: Number(behind?.[1] ?? 0) }
39}
40
41export function parseCommits(stdout: string): Commit[] {
42 return stdout
43 .split('\n')
44 .filter(line => line !== '')
45 .map(line => {
46 const [hash = '', age = '', ...subject] = line.split('\t')
47
48 return { hash, age, subject: subject.join('\t') }
49 })
50}
51
52export function parseRefs(stdout: string): Map<string, RefRow> {
53 const rows = new Map<string, RefRow>()
54
55 for (const line of stdout.split('\n')) {
56 if (line === '') continue
57
58 const [branch = '', upstream = '', remoteRef = '', track = '', push = ''] = line.split('\t')
59
60 rows.set(branch, {
61 branch,
62 upstream: upstream === '' ? null : upstream,
63 remoteRef,
64 track,
65 push: push === '' ? null : push,
66 })
67 }
68
69 return rows
70}
71
72export function parseWorktrees(stdout: string, refs: Map<string, RefRow>, currentRoot: string | null): WorktreeRow[] {
73 const rows: WorktreeRow[] = []
74 let path: string | null = null
75 let branch: string | null = null
76
77 const flush = () => {
78 if (path === null) return
79
80 const ref = branch === null ? undefined : refs.get(branch)
81
82 rows.push({
83 path,
84 name: basename(path),
85 branch,
86 upstream: ref?.upstream ?? null,
87 status: judgeUpstream(branch, ref?.remoteRef ?? '', ref?.track ?? ''),
88 isCurrent: path === currentRoot,
89 })
90 path = null
91 branch = null
92 }
93
94 for (const line of stdout.split('\n')) {
95 if (line.startsWith('worktree ')) {
96 flush()
97 path = line.slice('worktree '.length)
98 } else if (line.startsWith('branch refs/heads/')) {
99 branch = line.slice('branch refs/heads/'.length)
100 } else if (line === '') {
101 flush()
102 }
103 }
104 flush()
105
106 return rows
107}
108
109export async function readGitSnapshot(run: GitRun, cwd: string, commitCount: number, readAt: number): Promise<GitSnapshot> {
110 const empty: GitSnapshot = {
111 isRepo: false,
112 cwd,
113 mainRoot: null,
114 projectName: basename(cwd),
115 remoteName: null,
116 branch: null,
117 worktree: null,
118 upstream: null,
119 upstreamStatus: 'none',
120 pushTarget: null,
121 ahead: 0,
122 behind: 0,
123 dirty: 0,
124 commits: [],
125 worktrees: [],
126 readAt,
127 }
128
129 const layout = await run(['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir', '--abbrev-ref', 'HEAD'])
130 if (layout.exitCode !== 0) return empty
131
132 const [toplevel = '', commonDir = '', head = ''] = layout.stdout.split('\n')
133 const mainRoot = commonDir.endsWith('/.git') ? commonDir.slice(0, -'/.git'.length) : toplevel
134 const branch = head === '' || head === 'HEAD' ? null : head
135
136 const [remote, refs, status, log, worktrees] = await Promise.all([
137 run(['config', '--get', 'remote.origin.url']),
138 run(['for-each-ref', '--format=%(refname:short)%09%(upstream:short)%09%(upstream:remoteref)%09%(upstream:track)%09%(push:short)', 'refs/heads/']),
139 run(['status', '--porcelain']),
140 run(['log', `--format=%h%x09%ar%x09%s`, '-n', String(commitCount)]),
141 run(['worktree', 'list', '--porcelain']),
142 ])
143
144 const refRows = parseRefs(refs.stdout)
145 const current = branch === null ? undefined : refRows.get(branch)
146 const upstream = current?.upstream ?? null
147 const { ahead, behind } = parseTrack(current?.track ?? '')
148
149 return {
150 isRepo: true,
151 cwd,
152 mainRoot,
153 projectName: basename(mainRoot),
154 remoteName: remote.exitCode === 0 ? parseRemoteName(remote.stdout.trim()) : null,
155 branch,
156 worktree: toplevel === mainRoot ? null : basename(toplevel),
157 upstream,
158 upstreamStatus: judgeUpstream(branch, current?.remoteRef ?? '', current?.track ?? ''),
159 pushTarget: current?.push !== null && current?.push !== undefined && current.push !== upstream ? current.push : null,
160 ahead,
161 behind,
162 dirty: status.stdout.split('\n').filter(line => line !== '').length,
163 commits: log.exitCode === 0 ? parseCommits(log.stdout) : [],
164 worktrees: parseWorktrees(worktrees.stdout, refRows, toplevel),
165 readAt,
166 }
167}
168hooks/palette.ts 52 lines1export type ThemeName = 'light' | 'dark'
2
3export type Palette = {
4 background: string
5 accent: string
6 text: string
7 muted: string
8 hash: string
9 ok: string
10 warning: string
11 error: string
12}
13
14export const PALETTES: Record<ThemeName, Palette> = {
15 light: {
16 background: '#f7f7f7',
17 accent: '#0f54d6',
18 text: '#202020',
19 muted: '#606060',
20 hash: '#6b2fba',
21 ok: '#1b6600',
22 warning: '#826a00',
23 error: '#ad0f00',
24 },
25 dark: {
26 background: '#181825',
27 accent: '#cba6f7',
28 text: '#cdd6f4',
29 muted: '#a6adc8',
30 hash: '#b4befe',
31 ok: '#a6e3a1',
32 warning: '#fab387',
33 error: '#f38ba8',
34 },
35}
36
37export function resolveTheme(option: unknown, settingsTheme: unknown): ThemeName {
38 if (option === 'light' || option === 'dark') return option
39
40 return typeof settingsTheme === 'string' && settingsTheme.startsWith('light') ? 'light' : 'dark'
41}
42
43// A custom theme is stored as custom:<slug> and is a preset plus overrides;
44// the slug names ~/.claude/themes/<slug>.json, whose "base" is that preset.
45export function customThemeSlug(settingsTheme: unknown): string | null {
46 if (typeof settingsTheme !== 'string' || !settingsTheme.startsWith('custom:')) return null
47
48 const slug = settingsTheme.slice('custom:'.length)
49
50 return /^[A-Za-z0-9_-]+$/.test(slug) ? slug : null
51}
52hooks/raster.ts 74 lines1export type PixelArt = {
2 width: number
3 height: number
4 pixels: string
5}
6
7export type RasterCells = {
8 columns: number
9 rows: number
10 cells: string
11}
12
13const UPPER_HALF_BLOCK = 0x2580
14const BACKGROUND_LUMA_LIMIT = 40
15const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
16
17export function parseHexColor(hex: string): number {
18 return Number.parseInt(hex.replace(/^#/, ''), 16)
19}
20
21function luma(color: number): number {
22 const red = (color >> 16) & 0xff
23 const green = (color >> 8) & 0xff
24 const blue = color & 0xff
25
26 return (red * 299 + green * 587 + blue * 114) / 1000
27}
28
29function pixelAt(art: PixelArt, x: number, y: number, background: number): number {
30 if (y >= art.height) return background
31
32 const offset = (y * art.width + x) * 6
33 const color = Number.parseInt(art.pixels.slice(offset, offset + 6), 16)
34
35 return luma(color) < BACKGROUND_LUMA_LIMIT ? background : color
36}
37
38export function encodeBase64(bytes: Uint8Array): string {
39 let out = ''
40
41 for (let index = 0; index < bytes.length; index += 3) {
42 const first = bytes[index] ?? 0
43 const second = bytes[index + 1]
44 const third = bytes[index + 2]
45 const triple = (first << 16) | ((second ?? 0) << 8) | (third ?? 0)
46
47 out += BASE64[(triple >> 18) & 63]
48 out += BASE64[(triple >> 12) & 63]
49 out += second === undefined ? '=' : BASE64[(triple >> 6) & 63]
50 out += third === undefined ? '=' : BASE64[triple & 63]
51 }
52
53 return out
54}
55
56export function makeHalfBlockCells(art: PixelArt, backgroundHex: string): RasterCells {
57 const background = parseHexColor(backgroundHex)
58 const columns = art.width
59 const rows = Math.ceil(art.height / 2)
60 const words = new Uint32Array(columns * rows * 3)
61
62 for (let row = 0; row < rows; row += 1) {
63 for (let column = 0; column < columns; column += 1) {
64 const cell = (row * columns + column) * 3
65
66 words[cell] = UPPER_HALF_BLOCK
67 words[cell + 1] = pixelAt(art, column, row * 2, background)
68 words[cell + 2] = pixelAt(art, column, row * 2 + 1, background)
69 }
70 }
71
72 return { columns, rows, cells: encodeBase64(new Uint8Array(words.buffer)) }
73}
74hooks/recap.ts 72 lines1import type { TurnRecap } from '../types'
2
3export const RECAP_ROOTS_KEY = 'recap-roots'
4export const RECAP_ROOTS_LIMIT = 20
5
6export function recapKey(repoRoot: string): string {
7 return `recap:${repoRoot}`
8}
9
10export function firstLines(answer: string, count: number): string[] {
11 return answer
12 .split('\n')
13 .map(line => line.replace(/^\s*(?:#{1,6}\s+|[-*]\s+|\d+\.\s+|>\s*)/, '').replace(/[`*]/g, '').trim())
14 .filter(line => line !== '' && !/^[-=]{3,}$/.test(line))
15 .slice(0, count)
16}
17
18export function formatDuration(ms: number): string {
19 const seconds = Math.round(ms / 1000)
20 if (seconds < 60) return `${seconds}s`
21
22 const minutes = Math.floor(seconds / 60)
23 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
24
25 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
26}
27
28export function formatTokens(count: number): string {
29 if (count < 1000) return String(count)
30 if (count < 10000) return `${(count / 1000).toFixed(1)}k`
31
32 return `${Math.round(count / 1000)}k`
33}
34
35export function relativeAge(fromMs: number, nowMs: number): string {
36 const seconds = Math.max(0, Math.round((nowMs - fromMs) / 1000))
37 if (seconds < 60) return 'just now'
38
39 const minutes = Math.floor(seconds / 60)
40 if (minutes < 60) return `${minutes}m ago`
41
42 const hours = Math.floor(minutes / 60)
43 if (hours < 48) return `${hours}h ago`
44
45 return `${Math.floor(hours / 24)}d ago`
46}
47
48export function makeRecap(input: {
49 answer: string
50 durationMs: number
51 toolCalls: number
52 outputTokens: number | null
53 reason: string
54 repoRoot: string | null
55 endedAt: number
56 lineCount: number
57}): TurnRecap {
58 return {
59 repoRoot: input.repoRoot,
60 endedAt: input.endedAt,
61 durationMs: input.durationMs,
62 toolCalls: input.toolCalls,
63 outputTokens: input.outputTokens,
64 reason: input.reason,
65 lines: firstLines(input.answer, input.lineCount),
66 }
67}
68
69export function rememberRoot(roots: readonly string[], repoRoot: string): string[] {
70 return [repoRoot, ...roots.filter(root => root !== repoRoot)].slice(0, RECAP_ROOTS_LIMIT)
71}
72types/index.d.ts 64 lines1export type UpstreamStatus = 'ok' | 'mismatch' | 'none' | 'gone'
2
3export type Commit = {
4 hash: string
5 age: string
6 subject: string
7}
8
9export type WorktreeRow = {
10 path: string
11 name: string
12 branch: string | null
13 upstream: string | null
14 status: UpstreamStatus
15 isCurrent: boolean
16}
17
18export type GitSnapshot = {
19 isRepo: boolean
20 cwd: string
21 mainRoot: string | null
22 projectName: string
23 remoteName: string | null
24 branch: string | null
25 worktree: string | null
26 upstream: string | null
27 upstreamStatus: UpstreamStatus
28 pushTarget: string | null
29 ahead: number
30 behind: number
31 dirty: number
32 commits: Commit[]
33 worktrees: WorktreeRow[]
34 readAt: number
35}
36
37export type TurnRecap = {
38 repoRoot: string | null
39 endedAt: number
40 durationMs: number
41 toolCalls: number
42 outputTokens: number | null
43 reason: string
44 lines: string[]
45}
46
47export type SessionFacts = {
48 model: string
49 turns: number
50 contextPercent: number | null
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'dev-dashboard': {
56 git: GitSnapshot | null
57 lastTurn: TurnRecap | null
58 previous: TurnRecap | null
59 session: SessionFacts | null
60 isDismissed: boolean
61 }
62 }
63}
64