SLOPSHOPPER

dev-dashboard

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

newpanebandguardcommandprompt
v0.1.0no licenseupdated 2026-10-09eyedroot/dotfiles/.claude/mods/dev-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ██▀▀▄▄ ██▀▀▀▀ ██ ██ ██▀▀▄▄ ▄▄▀▀▄▄ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▄▄▀▀▀▀ ██ ██ │ ┃ ██ ██ ██▀▀ ██ ██ ██ ██ ██▀▀██ ✻ Worked for 42s · done 4:20 PM │ ┃ ▀▀▄▄ ██▀▀██ │ ┃ ▀▀▀▀ ▀▀▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀ › /dashboard │ ┃ ▀▀▀▀ ▀▀ ▀▀ ⎿ dev-dashboard: Dashboard opened. │ ┃ │ ┃ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 │ ┃ │ ┃ ── Session │ ┃ ────────────────────────────────────────── │ ┃ ─ │ ┃ claude-opus-5-5 · 1 turns · ctx 49% │ ┃ │ ┃ ── Git │ ┃ ────────────────────────────────────────── │ ┃ ───── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ██▀▀▄▄ ██▀▀▀▀ ██ ██ ██▀▀▄▄ ▄▄▀▀▄▄ ▄▄▀▀▀▀ ██ ██ ██ ██ ██▀▀ ██ ██ ██ ██ ██▀▀██ ▀▀▄▄ ██▀▀██ ▀▀▀▀ ▀▀▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀ ▀▀▀▀ ▀▀ ▀▀ a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 ── Session ─────────────────────────────────────────── claude-opus-5-5 · 1 turns · ctx 49% ── Git ─────────────────────────────────────────────── branch HEAD (detached) upstream none no upstream, first push sets it sync ↑0 ↓0 2 changed ── Last turn ───────────────────────────────────────── just now · 0s · 9 tools · 1.5k out Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One test still fails (revokes on logout), which looks unrelated to this change. ── Commits ─────────────────────────────────────────── a1b2c3d 9f8e7d6 [ Refresh ] [ Hide ]
README

dev-dashboard

A Claude Code mod (function-hooks plugin) that docks a pane beside the transcript with the facts a developer keeps checking by hand:

  • project name and the owner/name of origin
  • the session's model, turn count and context fill
  • current branch, the worktree it lives in, and the upstream branch with a verdict: ok 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 unset
  • ahead/behind counts against that upstream and the number of changed files
  • the other worktrees of the repository, listed one per entry with its own upstream verdict
  • a recap of the last turn: how long it took, how many tools ran, the answer's first lines, and the same for the last session that ran in this repository
  • recent commits

Loading

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

Checking

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-dashboard
Source 8 files
hooks/register.tsx 421 lines
1import { 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}
421
hooks/banner.ts 91 lines
1export 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}
91
hooks/flower.ts 23 lines
1import 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}
23
hooks/git.ts 168 lines
1import 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}
168
hooks/palette.ts 52 lines
1export 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}
52
hooks/raster.ts 74 lines
1export 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}
74
hooks/recap.ts 72 lines
1import 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}
72
types/index.d.ts 64 lines
1export 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