SLOPSHOPPER

dash-pane

Session dashboard pane: workspace, git, agent, tasks, limits and cost

newpaneguardcommandpromptprocess
v0.1.0no licenseupdated 2026-10-05nikbrunner/dots/common/.claude/mods/dash-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash-pane
│ ┃ Dash ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ cwd /work/app ⏺ Read(src/auth.ts) │ ┃ age 30m00 ⎿ Read 6 lines │ ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… ⏺ Update(src/auth.ts) │ ┃ 󰧑 Opus 5.5 ⎿ Added 2 lines, removed 1 line │ ┃ turns 1 ⏺ Bash(bun test) │ ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… ⎿ 3 pass, 1 fail │ ┃ session │ ┃ context ▰▰▰▰▰▰▱▱▱▱▱▱ 49% 97k/200k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 5h ▰▰▰▰▱▱▱▱▱▱▱▱ 31% ↓59m59 │ ┃ cache – · cold ✻ Worked for 42s · done 4:20 PM │ ┃ edits +16/-1 lines │ ┃ cost $0.42 › /dash-pane │ ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… ⎿ dash-pane: Dash pane opened. │ ┃ detached │ ┃ changes ✓ clean │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dash
app cwd /work/app age 30m00 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… 󰧑 Opus 5.5 turns 1 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… session context ▰▰▰▰▰▰▱▱▱▱▱▱ 49% 97k/200k 5h ▰▰▰▰▱▱▱▱▱▱▱▱ 31% ↓59m59 cache – · cold edits +16/-1 lines cost $0.42 ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… detached changes ✓ clean
README

dots

A clean, organized dotfiles repository using symlinks for easy management and deployment.

📚 Table of Contents

Overview

This dotfiles system uses a unified YAML configuration for symlink management:

  • Configuration files live in this repository organized by platform
  • Symlinks are defined in a single symlinks.yml file with OS-specific sections
  • Running dots link updates everything automatically (removes broken symlinks + creates new ones)
  • Supports wildcard patterns for flexible file management (e.g., "common/.local/bin/*": "~/.local/bin")
  • Eliminates duplication with shared common entries across platforms

Installation

Prerequisites

SSH access to GitHub via ProtonPass is required before cloning.

Complete Machine Setup

See install/README.md for the full bootstrap guide.

Quick version:

git clone git@github.com:nikbrunner/dots.git ~/repos/nikbrunner/dots
cd ~/repos/nikbrunner/dots
./install/install.sh

Flags: --dry-run (preview), --debug (diagnostics)

Usage

Core Commands

  • dots — Dotfiles management (pull, push, link, chores, purge). Run dots with no args for usage.
  • dots pull — Installs missing mise and platform-native packages without upgrading existing packages. Add --upgrade to upgrade declared packages.
  • dots chores — Refreshes the mise lock metadata and stages config.toml with mise.lock alongside routine changes. It does not bump fuzzy version selectors.
  • dots purge — Reviews undeclared Homebrew, mise, and Arch entries. Nothing is selected by default; removal requires explicit selection and confirmation. Use --dry-run to inspect candidates without changes.
  • mole uninstall — Separate macOS application cleanup. It does not replace dots purge for package-manager entries.
  • shiplog — AI-powered git operations (commit, branch). Run shiplog --help for usage.
  • helm — External tool for multi-repo management. Invoked by dots pull and dots push.

Managing Configurations

Adding a Configuration
  1. Place file in common/ (cross-platform) or macos//arch/ (OS-specific), mirroring home directory structure
  2. Add entry to symlinks.yml in the appropriate section
  3. Run dots link
Removing a Configuration
  1. Delete the file from the repository
  2. Remove the entry from symlinks.yml
  3. Run dots link (broken symlinks are automatically cleaned up)
Renaming/Moving a Configuration
  1. Move the file in the repository
  2. Run dots link (old symlink removed, new one created)
Preview Changes
dots link --dry-run --verbose

How It Works

When you run dots link:

  1. Loads configuration: Reads the symlinks.yml file for your platform
  2. Cleans up: Removes any broken symlinks from previous configurations
  3. Processes entries: Creates symlinks as defined in the configuration:
  4. Directory symlinks for entire directories
  5. File symlinks for individual files
  6. Wildcard expansion for patterns like "common/.local/bin/*": "~/.local/bin"
  7. Backs up conflicts: If a real file exists where a symlink should go, it's backed up with a timestamp (unless --no-backup is used)

Options:

  • --dry-run: Preview what would happen without making changes
  • --no-backup: Overwrite existing files instead of backing them up
  • --verbose: Show detailed output for each symlink operation

OS-Specific Configurations

Place OS-specific files in macos/, linux/, or arch/ following the home directory structure. The system uses the symlinks.yml configuration file with OS-specific sections to define which files get symlinked.

Important: The YAML section names must match the OS detection output:

Detected OSYAML SectionDescription
commoncommon:Always processed on all platforms
macosmacos:macOS systems
archarch:Arch Linux systems

The system always processes the common section first, then adds the platform-specific section if it exists. OS detection is handled by scripts/dots/detect-os.sh.

Black Atom Theme Integration

Black Atom Livery owns theme provisioning. The tracked configuration at common/.config/black-atom/livery/config.json records the active theme and enabled app integrations.

dots pull runs livery reapply after linking files and refreshing repositories when the configuration contains setup data and an active theme. Run livery apply to choose a different theme interactively.

Source 3 files
hooks/register.tsx 571 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionUsage } from 'claude-code'
3
4import type { Agent, Task, Usage } from '../types'
5import { bar, editDelta, duration, heat, parseGit, prettyModel, until } from './lib'
6
7const DASH = 'dash'
8const CACHE_TTL_MS = 60 * 60 * 1000
9
10const git = atom({ plugin: 'dash-pane', key: 'git' } as const, null)
11const agent = atom({ plugin: 'dash-pane', key: 'agent' } as const, {
12  isWork: false,
13  model: '',
14})
15const title = atom({ plugin: 'dash-pane', key: 'title' } as const, '')
16const tasks = atom({ plugin: 'dash-pane', key: 'tasks' } as const, [])
17const usage = atom({ plugin: 'dash-pane', key: 'usage' } as const, {
18  limits: [],
19})
20const cache = atom({ plugin: 'dash-pane', key: 'cache' } as const, {
21  misses: 0,
22})
23const lines = atom({ plugin: 'dash-pane', key: 'lines' } as const, {
24  added: 0,
25  removed: 0,
26})
27const tick = atom({ plugin: 'dash-pane', key: 'tick' } as const, 0)
28const work = atom({ plugin: 'dash-pane', key: 'work' } as const, {
29  cwd: '',
30  turns: 0,
31})
32
33const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
34const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
35const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate'])
36const FILE_COLOR: Record<string, string> = {
37  A: 'success',
38  M: 'warning',
39  D: 'error',
40  R: 'suggestion',
41  C: 'suggestion',
42  '?': 'suggestion',
43  U: 'error',
44}
45const ICON = {
46  dir: '\uf07b',
47  branch: '\uf418',
48  ctx: '\uf0e4',
49  cache: '\uf06d',
50  brain: '\u{f09d1}',
51  tasks: '☰',
52}
53// Stroke icons in a 24×24 box, drawn on surfaces whose font has no Nerd Font glyphs.
54const ICON_SVG: Record<keyof typeof ICON, string> = {
55  dir: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
56  branch: '<path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
57  ctx: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
58  cache:
59    '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
60  brain: '<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
61  tasks: '<path d="M10 6h11"/><path d="M10 12h11"/><path d="M10 18h11"/><path d="m3 6 1.5 1.5L7 5"/><path d="m3 12 1.5 1.5L7 11"/><path d="m3 18 1.5 1.5L7 17"/>',
62}
63const SVG_COLOR: Record<string, string> = {
64  claude: '#d77757',
65  success: '#4eba65',
66  warning: '#ffc107',
67  dim: '#999999',
68}
69const LIMIT_LABEL: Record<string, string> = {
70  five_hour: '5h',
71  seven_day: '7d',
72}
73
74let configDir = ''
75let home = ''
76let isGitQueued = false
77
78async function refreshGit($: EngineInterface) {
79  const cwd = await $.session.cwd()
80  const run = await $.process.run(
81    [
82      'sh',
83      '-c',
84      'cd "$(git rev-parse --show-toplevel)" || exit 1; ' +
85        'git --no-optional-locks status --porcelain=v2 --branch || exit 1; ' +
86        'printf "# gitdir %s\\n" "$(git rev-parse --absolute-git-dir)"; ' +
87        'git log -1 --format="# last %h%x09%cr%x09%s" 2>/dev/null; ' +
88        'printf "# stash %s\\n" "$(git stash list 2>/dev/null | wc -l | tr -d " ")"; ' +
89        'git --no-optional-locks diff --numstat HEAD 2>/dev/null | sed "s/^/# num /"',
90    ],
91    { cwd, timeoutMs: 3000 },
92  )
93  const root = (await $.session.repo())?.root ?? cwd
94  const repo = root.split('/').pop() ?? root
95  await update($, git, () => (run.exitCode === 0 ? parseGit(repo, run.stdout) : null))
96}
97
98function queueGit($: EngineInterface) {
99  if (isGitQueued) return
100  isGitQueued = true
101  $.clock.after(500, () => {
102    isGitQueued = false
103    void refreshGit($)
104  })
105}
106
107async function refreshTasks($: EngineInterface) {
108  const dir = `${configDir}/tasks/${await $.session.id()}`
109  if (!(await $.fs.exists(dir))) return
110  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
111  const list: Task[] = []
112  for (const f of files) {
113    try {
114      const t = JSON.parse(await $.fs.read(`${dir}/${f.name}`))
115      list.push({ subject: t.activeForm ?? t.subject ?? '', status: t.status })
116    } catch {}
117  }
118  await update($, tasks, () => list)
119}
120
121async function loadAgent($: EngineInterface) {
122  const paths = await $.process.run([
123    'sh',
124    '-c',
125    'printf "%s\\n%s\\n%s" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$HOME"',
126  ])
127  const [dir = '', configFile = '', homeDir = ''] = paths.stdout.split('\n')
128  configDir = dir
129  home = homeDir
130  const auth = await $.session.authorize()
131  let account: string | undefined
132  try {
133    account = JSON.parse(await $.fs.read(configFile)).oauthAccount?.emailAddress?.split('@')[0]
134  } catch {}
135  const model = prettyModel(await $.session.model())
136  await update($, agent, a => ({
137    ...a,
138    model,
139    account,
140    isWork: auth?.kind === 'api-key',
141  }))
142}
143
144async function refreshTitle($: EngineInterface) {
145  const run = await $.process.run([
146    'sh',
147    '-c',
148    'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
149      'grep -o \'"\\(customTitle\\|aiTitle\\)":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
150    'sh',
151    configDir,
152    await $.session.id(),
153  ])
154  const found = run.stdout.trim()
155  if (found) await update($, title, () => found)
156}
157
158async function refreshWork($: EngineInterface) {
159  const cwd = await $.session.cwd()
160  const turns = await $.session.turns()
161  await update($, work, w => ({
162    ...w,
163    cwd: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
164    turns,
165  }))
166}
167
168function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
169  return {
170    context: u.context.percent,
171    tokens: u.context.tokens,
172    window: u.context.window,
173    startedAt: 'startedAt' in u ? (u as SessionUsage).startedAt : undefined,
174    limits: u.rateLimits.map(l => ({
175      kind: l.kind,
176      percent: l.percentUsed,
177      resetsAt: l.resetsAt,
178    })),
179    cost: u.cost?.usd,
180  }
181}
182
183async function refreshUsage($: EngineInterface) {
184  const u = await $.session.usage()
185  await update($, usage, () => toUsage(u))
186}
187
188export const register: Register = on => {
189  on('session.start', async ($, e, next) => {
190    await $.command.register({
191      name: 'dash-pane',
192      description: 'Open the session dashboard pane',
193    })
194    void $.ui.open({ id: DASH, title: 'Dash' })
195    await loadAgent($)
196    void refreshTitle($)
197    void refreshWork($)
198    void refreshGit($)
199    void refreshTasks($)
200    void refreshUsage($)
201    $.clock.every(1000, () => void update($, tick, n => n + 1))
202    return next(e)
203  })
204
205  on('command.run', { command: 'dash-pane' }, async $ => {
206    await $.ui.open({ id: DASH, title: 'Dash' })
207    return { text: 'Dash pane opened.' }
208  })
209
210  on('classic.UserPromptSubmit', async ($, e, next) => {
211    if (e.session_title) await update($, title, () => e.session_title ?? '')
212    const mode = e.permission_mode
213    if (mode) void update($, work, w => ({ ...w, mode }))
214    return next(e)
215  })
216
217  on('prompt.submit', async ($, e, next) => {
218    queueGit($)
219    void refreshUsage($)
220    void refreshWork($)
221    void $.session.model().then(m => update($, agent, a => ({ ...a, model: prettyModel(m) })))
222    return next(e)
223  })
224
225  on('prompt.compose', async ($, e, next) => {
226    const style = e.outputStyle?.name
227    void update($, agent, a => ({ ...a, style }))
228    return next(e)
229  })
230
231  on('turn.step', async function* ($, e, next) {
232    const result = yield* next(e)
233    if (e.agentId) return result
234    if (e.effort !== undefined) void update($, agent, a => ({ ...a, effort: String(e.effort) }))
235    const u = result.usage
236    if (u) {
237      const at = await $.clock.now()
238      const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
239      await update($, cache, c => {
240        const wasWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
241        const isMiss = wasWarm && u.cache_read_input_tokens === 0
242        return {
243          hit: total > 0 ? u.cache_read_input_tokens / total : c.hit,
244          lastAt: at,
245          misses: c.misses + (isMiss ? 1 : 0),
246        }
247      })
248    }
249    return result
250  })
251
252  on('tool.call', async ($, e, next) => {
253    if (EDIT_TOOLS.has(e.tool)) {
254      const delta = editDelta(e as unknown as Record<string, unknown>)
255      const ran = await next(e)
256      if (!ran.isError)
257        void update($, lines, l => ({
258          added: l.added + delta.added,
259          removed: l.removed + delta.removed,
260        }))
261      queueGit($)
262      return ran
263    }
264    const ran = await next(e)
265    if (GIT_TOOLS.has(e.tool)) queueGit($)
266    if (!e.agentId && TASK_TOOLS.has(e.tool)) void refreshTasks($)
267    return ran
268  })
269
270  on('turn.complete', async ($, e, next) => {
271    const result = await next(e)
272    if (!e.agentId) {
273      void refreshTitle($)
274      void refreshWork($)
275    }
276    return result
277  })
278
279  on('session.measure', async ($, e, next) => {
280    await update($, usage, prev => ({
281      ...toUsage(e),
282      startedAt: prev.startedAt,
283    }))
284    return next(e)
285  })
286
287  on('ui.render', { component: 'Pane', requestId: DASH }, async ($, e) => {
288    const els = $.ui.resolve(e)
289    const { Box, Text } = els
290    // The desktop app's font carries no Nerd Font glyphs and lays text out proportionally.
291    const isDesktop = e.surface === 'desktop'
292    const Svg = 'Svg' in els ? els.Svg : undefined
293    const mark = (key: keyof typeof ICON, color: string) =>
294      isDesktop && Svg ? (
295        <Svg
296          alt={key}
297          width={14}
298          height={14}
299          source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${SVG_COLOR[color] ?? color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICON_SVG[key]}</svg>`}
300        />
301      ) : (
302        <Text color={color === 'dim' ? undefined : color} dimColor={color === 'dim'}>
303          {ICON[key]}
304        </Text>
305      )
306    const g = await read($, git)
307    const a: Agent = await read($, agent)
308    const t = await read($, title)
309    const tk = await read($, tasks)
310    const u = await read($, usage)
311    const c = await read($, cache)
312    const ln = await read($, lines)
313    const w = await read($, work)
314    await read($, tick)
315    const at = await $.clock.now()
316    const done = tk.filter(x => x.status === 'completed').length
317    const isCacheWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
318    const room = Math.max(4, (e.viewport?.rows ?? 40) - 32 - (tk.length > 0 && done < tk.length ? tk.length + 2 : 0))
319
320    const rule = '╌'.repeat(Math.max(10, (e.viewport?.columns ?? 40) - 2))
321    let isFirstCard = true
322    const card = (icon: keyof typeof ICON, name: string, meta: RenderChildren, body: RenderChildren, accent = 'claude') => {
323      const isFirst = isFirstCard
324      isFirstCard = false
325      return (
326        <Box flexDirection="column" paddingX={1}>
327          {!isFirst &&
328            (isDesktop ? (
329              <Box marginTop={1} />
330            ) : (
331              <Text dimColor wrap="truncate-end">
332                {rule}
333              </Text>
334            ))}
335          <Box justifyContent="space-between">
336            <Box gap={1} alignItems="center">
337              {mark(icon, accent)}
338              <Text color={accent} bold>
339                {name}
340              </Text>
341            </Box>
342            {meta}
343          </Box>
344          {body}
345        </Box>
346      )
347    }
348
349    const LABEL = 9
350    const kv = (
351      name: string,
352      value: RenderChildren,
353      wrap: 'truncate-end' | 'truncate-start' = 'truncate-end',
354      lead?: RenderChildren,
355    ) => (
356      <Box alignItems="center">
357        <Box width={LABEL} flexShrink={0}>
358          <Text color="inactive">{name}</Text>
359        </Box>
360        {lead && (
361          <Box marginRight={1} flexShrink={0}>
362            {lead}
363          </Box>
364        )}
365        <Text wrap={wrap}>{value}</Text>
366      </Box>
367    )
368
369    const gauge = (percent: number, width: number, fill = 'rate_limit_fill') => {
370      if (isDesktop) {
371        const pct = Math.min(100, Math.max(0, percent))
372        return (
373          <Box width={width} height={1} flexShrink={0} alignItems="center">
374            <Box width="100%" height={0.4} backgroundColor="rate_limit_empty" overflow="hidden">
375              <Box width={`${pct}%`} backgroundColor={fill} />
376            </Box>
377          </Box>
378        )
379      }
380      const b = bar(percent, width)
381      const n = b.lastIndexOf('▰') + 1
382      return (
383        <Text>
384          <Text color={fill}>{b.slice(0, n)}</Text>
385          <Text color="rate_limit_empty">{b.slice(n)}</Text>
386        </Text>
387      )
388    }
389
390    const meter = (name: string, percent: number, extra: string) => (
391      <Box justifyContent="space-between">
392        <Box>
393          <Box width={LABEL} flexShrink={0}>
394            <Text color="inactive">{name}</Text>
395          </Box>
396          {gauge(percent, 12, heat(percent))}
397          <Text bold> {String(Math.round(percent)).padStart(3)}%</Text>
398        </Box>
399        <Text dimColor>{extra}</Text>
400      </Box>
401    )
402
403    return (
404      <Box flexDirection="column">
405        {card(
406          'dir',
407          g?.repo ?? '-',
408          g?.isWorktree ? <Text color="suggestion">worktree</Text> : null,
409          <Box flexDirection="column">
410            {kv('cwd', <Text>{w.cwd}</Text>, 'truncate-start')}
411            {t !== '' &&
412              kv(
413                'session',
414                <Text color="suggestion" italic>
415                  {t}
416                </Text>,
417              )}
418            {u.startedAt !== undefined && kv('age', <Text>{duration(at - u.startedAt)}</Text>)}
419          </Box>,
420        )}
421
422        {card(
423          'brain',
424          a.model,
425          a.isWork ? (
426            <Text color="error" bold>
427              ⚠ WORK
428            </Text>
429          ) : null,
430          <Box flexDirection="column">
431            {a.account && kv('account', <Text color="suggestion">{a.account}</Text>)}
432            {a.effort && kv('effort', <Text color="warning">{a.effort}</Text>)}
433            {w.mode && kv('mode', <Text>{w.mode}</Text>)}
434            {a.style && a.style !== 'default' && kv('style', <Text>{a.style}</Text>)}
435            {kv('turns', <Text>{w.turns}</Text>)}
436          </Box>,
437          'success',
438        )}
439
440        {tk.length > 0 &&
441          done < tk.length &&
442          card(
443            'tasks',
444            'tasks',
445            <Box>
446              {gauge((done / tk.length) * 100, 8)}
447              <Text bold>
448                {' '}
449                {done}/{tk.length}
450              </Text>
451            </Box>,
452            <Box flexDirection="column">
453              {tk.map(x => (
454                <Text
455                  dimColor={x.status === 'completed'}
456                  color={x.status === 'in_progress' ? 'warning' : undefined}
457                  wrap="truncate-end"
458                >
459                  {x.status === 'completed' ? '✓ ' : x.status === 'in_progress' ? '▸ ' : '· '}
460                  {x.subject}
461                </Text>
462              ))}
463            </Box>,
464          )}
465
466        {card(
467          'ctx',
468          'session',
469          null,
470          <Box flexDirection="column">
471            {u.context !== undefined &&
472              meter(
473                'context',
474                u.context,
475                u.tokens !== undefined && u.window !== undefined
476                  ? `${Math.round(u.tokens / 1000)}k/${u.window >= 1_000_000 ? `${u.window / 1_000_000}M` : `${Math.round(u.window / 1000)}k`}`
477                  : '',
478              )}
479            {u.limits.map(l => meter(LIMIT_LABEL[l.kind] ?? l.kind, l.percent, until(l.resetsAt, at)))}
480            {kv(
481              'cache',
482              <Text>
483                <Text>{c.hit !== undefined ? `${Math.round(c.hit * 100)}% hit` : '–'}</Text>
484                <Text dimColor>
485                  {' '}
486                  · {isCacheWarm && c.lastAt ? `expires ${until(c.lastAt + CACHE_TTL_MS, at)}` : 'cold'}
487                </Text>
488                {c.misses > 0 && <Text color="error"> · {c.misses} miss</Text>}
489              </Text>,
490              'truncate-end',
491              mark('cache', isCacheWarm ? 'warning' : 'dim'),
492            )}
493            {kv(
494              'edits',
495              <Text>
496                <Text color="success">+{ln.added}</Text>
497                <Text dimColor>/</Text>
498                <Text color="error">-{ln.removed}</Text>
499                <Text dimColor> lines</Text>
500              </Text>,
501            )}
502            {u.cost !== undefined && kv('cost', <Text color="warning">${u.cost.toFixed(2)}</Text>)}
503          </Box>,
504        )}
505
506        {g &&
507          card(
508            'branch',
509            g.branch ?? 'detached',
510            null,
511            <Box flexDirection="column">
512              {g.upstream &&
513                kv(
514                  'upstream',
515                  <Text>
516                    <Text>{g.upstream}</Text>
517                    {g.ahead > 0 && <Text color="success"> ↑{g.ahead}</Text>}
518                    {g.behind > 0 && <Text color="error"> ↓{g.behind}</Text>}
519                    {g.ahead === 0 && g.behind === 0 && <Text dimColor> in sync</Text>}
520                  </Text>,
521                )}
522              {g.last &&
523                kv(
524                  'last',
525                  <Text>
526                    <Text color="warning">{g.last.hash}</Text>
527                    <Text> {g.last.subject}</Text>
528                    <Text dimColor> · {g.last.age}</Text>
529                  </Text>,
530                )}
531              {g.stashes > 0 && kv('stashes', <Text color="suggestion">{g.stashes}</Text>)}
532              {kv(
533                'changes',
534                g.files.length === 0 ? (
535                  <Text color="success">✓ clean</Text>
536                ) : (
537                  <Text>
538                    {g.added > 0 && <Text color="success">+{g.added} added </Text>}
539                    {g.modified > 0 && <Text color="warning">~{g.modified} modified </Text>}
540                    {g.deleted > 0 && <Text color="error">-{g.deleted} deleted </Text>}
541                    {g.untracked > 0 && <Text color="suggestion">?{g.untracked} untracked</Text>}
542                  </Text>
543                ),
544              )}
545              {g.files.length > 0 && (
546                <Box flexDirection="column" marginTop={1}>
547                  {g.files.slice(0, room).map(f => (
548                    <Box justifyContent="space-between">
549                      <Text wrap="truncate-start">
550                        <Text color={FILE_COLOR[f.status] ?? 'warning'} bold={f.isStaged}>
551                          {f.isStaged ? '●' : '○'} {f.status}{' '}
552                        </Text>
553                        <Text>{f.path}</Text>
554                      </Text>
555                      <Text>
556                        {f.added !== undefined && <Text color="success">+{f.added}</Text>}
557                        {f.removed !== undefined && <Text color="error"> -{f.removed}</Text>}
558                      </Text>
559                    </Box>
560                  ))}
561                  {g.files.length > room && <Text dimColor>… {g.files.length - room} more</Text>}
562                  <Text dimColor>● staged ○ unstaged</Text>
563                </Box>
564              )}
565            </Box>,
566          )}
567      </Box>
568    )
569  })
570}
571
hooks/lib.ts 107 lines
1import type { Git, GitFile } from '../types'
2
3export function parseGit(repo: string, out: string): Git {
4  const g: Git = {
5    repo,
6    isWorktree: false,
7    ahead: 0,
8    behind: 0,
9    added: 0,
10    modified: 0,
11    deleted: 0,
12    untracked: 0,
13    stashes: 0,
14    files: [],
15  }
16  const numstat = new Map<string, [number, number]>()
17  const file = (xy: string, path: string): GitFile => {
18    const [x = '.', y = '.'] = xy
19    return { path, status: x !== '.' ? x : y, isStaged: x !== '.' }
20  }
21  for (const line of out.split('\n')) {
22    if (line.startsWith('# branch.head ')) {
23      const head = line.slice(14)
24      g.branch = head === '(detached)' ? undefined : head
25    } else if (line.startsWith('# branch.ab ')) {
26      const [a, b] = line.slice(12).split(' ')
27      g.ahead = Math.abs(Number(a))
28      g.behind = Math.abs(Number(b))
29    } else if (line.startsWith('# branch.upstream ')) {
30      g.upstream = line.slice(18)
31    } else if (line.startsWith('# last ')) {
32      const [hash = '', age = '', subject = ''] = line.slice(7).split('\t')
33      g.last = { hash, age, subject }
34    } else if (line.startsWith('# stash ')) {
35      g.stashes = Number(line.slice(8)) || 0
36    } else if (line.startsWith('# num ')) {
37      const [a, r, path] = line.slice(6).split('\t')
38      if (path && a !== '-') numstat.set(path, [Number(a), Number(r)])
39    } else if (line.startsWith('# gitdir ')) {
40      g.isWorktree = line.includes('/worktrees/')
41    } else if (line.startsWith('? ')) {
42      g.untracked++
43      g.files.push({ path: line.slice(2), status: '?', isStaged: false })
44    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
45      const parts = line.split(' ')
46      const xy = parts[1] ?? ''
47      const path = (line.startsWith('2 ') ? parts.slice(9) : parts.slice(8)).join(' ').split('\t')[0] ?? ''
48      g.files.push(file(xy, path))
49      if (xy.includes('D')) g.deleted++
50      else if (xy.startsWith('A')) g.added++
51      else g.modified++
52    }
53  }
54  for (const f of g.files) {
55    const n = numstat.get(f.path)
56    if (n) [f.added, f.removed] = n
57  }
58  return g
59}
60
61export function prettyModel(id: string): string {
62  const m = id.match(/^claude-([a-z]+)-(\d+)-(\d+)/)
63  if (!m) return id
64  const name = `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}`
65  return id.includes('[1m]') ? `${name} (1M context)` : name
66}
67
68export function bar(percent: number, width = 10): string {
69  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
70  return '▰'.repeat(filled) + '▱'.repeat(width - filled)
71}
72
73export function heat(percent: number): string | undefined {
74  if (percent >= 85) return 'error'
75  if (percent >= 60) return 'warning'
76  return undefined
77}
78
79export function duration(ms: number): string {
80  const s = Math.max(0, Math.floor(ms / 1000))
81  if (s < 60) return `${s}s`
82  if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
83  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`
84}
85
86export function until(iso: string | number | undefined, now: number): string {
87  if (iso === undefined) return ''
88  const left = (typeof iso === 'number' ? iso : Date.parse(iso)) - now
89  if (!(left > 0)) return ''
90  if (left >= 86_400_000) return `↓${Math.floor(left / 86_400_000)}d`
91  return `↓${duration(left)}`
92}
93
94const countLines = (v: unknown) => (typeof v === 'string' && v !== '' ? v.split('\n').length : 0)
95
96export function editDelta(e: Record<string, unknown>): { added: number; removed: number } {
97  if (e.tool === 'Write') return { added: countLines(e.content), removed: 0 }
98  const edits = Array.isArray(e.edits) ? (e.edits as Record<string, unknown>[]) : [e]
99  let added = 0
100  let removed = 0
101  for (const one of edits) {
102    added += countLines(one.new_string)
103    removed += countLines(one.old_string)
104  }
105  return { added, removed }
106}
107
types/index.d.ts 58 lines
1export type Task = { subject: string; status: 'pending' | 'in_progress' | 'completed' }
2
3export type GitFile = {
4  path: string
5  status: string
6  isStaged: boolean
7  added?: number
8  removed?: number
9}
10
11export type Git = {
12  repo: string
13  upstream?: string
14  last?: { hash: string; age: string; subject: string }
15  stashes: number
16  files: GitFile[]
17  isWorktree: boolean
18  branch?: string
19  ahead: number
20  behind: number
21  added: number
22  modified: number
23  deleted: number
24  untracked: number
25}
26
27export type Limit = { kind: string; percent: number; resetsAt?: string }
28
29export type Usage = { context?: number; tokens?: number; window?: number; startedAt?: number; limits: Limit[]; cost?: number }
30
31export type Agent = {
32  account?: string
33  isWork: boolean
34  model: string
35  effort?: string
36  style?: string
37}
38
39export type Work = { cwd: string; mode?: string; turns: number }
40
41export type Cache = { hit?: number; lastAt?: number; misses: number }
42
43declare module 'claude-code' {
44  interface PluginState {
45    'dash-pane': {
46      git: Git | null
47      agent: Agent
48      title: string
49      work: Work
50      tasks: Task[]
51      usage: Usage
52      cache: Cache
53      lines: { added: number; removed: number }
54      tick: number
55    }
56  }
57}
58