SLOPSHOPPER

dash

Session dashboard pane: workspace, git, agent, tasks, limits, cost and what the conversation is working on

newbandguardpromptmodelprocess
v0.1.0no licenseupdated 2026-10-26nikbrunner/dots/common/.claude/mods/dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash
› 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 ⟨Claude Code's own drawing⟩ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ repo app /work/app · 30m00 usage ctx ▰▰▰▱▱▱ 49% 97k/200k 5h ▰▰▱▱▱▱ 31% ↓59m59 cache – · cold spend $0.42 · +16/-1 lines model Opus 5.5 1 turns branch detached changes ✓ clean ▸ OK · OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ repo app /work/app · 30m00 usage ctx ▰▰▰▱▱▱ 49% 97k/200k 5h ▰▰▱▱▱▱ 31% ↓59m59 cache – · cold spend $0.42 · +16/-1 lines model Opus 5.5 1 turns branch detached changes ✓ clean ▸ OK · OK
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 624 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, describeCall, editDelta, duration, heat, oneLine, parseGit, prettyModel, until } from './lib'
6
7const CACHE_TTL_MS = 60 * 60 * 1000
8const MODEL = 'haiku'
9const SUMMARY_INTERVAL_MS = 10_000
10
11const git = atom({ plugin: 'dash', key: 'git' } as const, null)
12const agent = atom({ plugin: 'dash', key: 'agent' } as const, {
13  isWork: false,
14  model: '',
15})
16const title = atom({ plugin: 'dash', key: 'title' } as const, '')
17const tasks = atom({ plugin: 'dash', key: 'tasks' } as const, [])
18const usage = atom({ plugin: 'dash', key: 'usage' } as const, {
19  limits: [],
20})
21const cache = atom({ plugin: 'dash', key: 'cache' } as const, {
22  misses: 0,
23})
24const lines = atom({ plugin: 'dash', key: 'lines' } as const, {
25  added: 0,
26  removed: 0,
27})
28const tick = atom({ plugin: 'dash', key: 'tick' } as const, 0)
29const thread = atom({ plugin: 'dash', key: 'thread' } as const, '')
30const now = atom({ plugin: 'dash', key: 'now' } as const, '')
31const tool = atom({ plugin: 'dash', key: 'tool' } as const, '')
32const panesHidden = { plugin: 'toggle-mods', key: 'isHidden' } as const
33const work = atom({ plugin: 'dash', key: 'work' } as const, {
34  cwd: '',
35  turns: 0,
36})
37
38const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
39const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
40const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate'])
41const ICON = {
42  cache: '\uf06d',
43}
44// Stroke icons in a 24×24 box, drawn on surfaces whose font has no Nerd Font glyphs.
45const ICON_SVG: Record<keyof typeof ICON, string> = {
46  cache:
47    '<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"/>',
48}
49const SVG_COLOR: Record<string, string> = {
50  claude: '#d77757',
51  success: '#4eba65',
52  warning: '#ffc107',
53  dim: '#999999',
54}
55const LIMIT_LABEL: Record<string, string> = {
56  five_hour: '5h',
57  seven_day: '7d',
58}
59
60let configDir = ''
61let home = ''
62let isGitQueued = false
63let prompt = ''
64let prompts: string[] = []
65let recent: string[] = []
66let lastSummaryAt = 0
67let isSummarizing = false
68let isThreading = false
69
70async function refreshGit($: EngineInterface) {
71  const cwd = await $.session.cwd()
72  const run = await $.process.run(
73    [
74      'sh',
75      '-c',
76      'cd "$(git rev-parse --show-toplevel)" || exit 1; ' +
77        'git --no-optional-locks status --porcelain=v2 --branch || exit 1; ' +
78        'printf "# gitdir %s\\n" "$(git rev-parse --absolute-git-dir)"; ' +
79        'git log -1 --format="# last %h%x09%cr%x09%s" 2>/dev/null; ' +
80        'printf "# stash %s\\n" "$(git stash list 2>/dev/null | wc -l | tr -d " ")"; ' +
81        'git --no-optional-locks diff --numstat HEAD 2>/dev/null | sed "s/^/# num /"',
82    ],
83    { cwd, timeoutMs: 3000 },
84  )
85  const root = (await $.session.repo())?.root ?? cwd
86  const repo = root.split('/').pop() ?? root
87  await update($, git, () => (run.exitCode === 0 ? parseGit(repo, run.stdout) : null))
88}
89
90function queueGit($: EngineInterface) {
91  if (isGitQueued) return
92  isGitQueued = true
93  $.clock.after(500, () => {
94    isGitQueued = false
95    void refreshGit($)
96  })
97}
98
99async function refreshTasks($: EngineInterface) {
100  const dir = `${configDir}/tasks/${await $.session.id()}`
101  if (!(await $.fs.exists(dir))) return
102  const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
103  const list: Task[] = []
104  for (const f of files) {
105    try {
106      const t = JSON.parse(await $.fs.read(`${dir}/${f.name}`))
107      list.push({ subject: t.activeForm ?? t.subject ?? '', status: t.status })
108    } catch {}
109  }
110  await update($, tasks, () => list)
111}
112
113async function loadAgent($: EngineInterface) {
114  const paths = await $.process.run([
115    'sh',
116    '-c',
117    'printf "%s\\n%s\\n%s" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$HOME"',
118  ])
119  const [dir = '', configFile = '', homeDir = ''] = paths.stdout.split('\n')
120  configDir = dir
121  home = homeDir
122  let account: string | undefined
123  try {
124    account = JSON.parse(await $.fs.read(configFile)).oauthAccount?.emailAddress?.split('@')[0]
125  } catch {}
126  const model = prettyModel(await $.session.model())
127  await update($, agent, a => ({
128    ...a,
129    model,
130    account,
131    isWork: dir.includes('/.claude-work'),
132  }))
133}
134
135async function refreshTitle($: EngineInterface) {
136  const run = await $.process.run([
137    'sh',
138    '-c',
139    'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
140      'grep -o \'"\\(customTitle\\|aiTitle\\)":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
141    'sh',
142    configDir,
143    await $.session.id(),
144  ])
145  const found = run.stdout.trim()
146  if (found) await update($, title, () => found)
147}
148
149async function refreshWork($: EngineInterface) {
150  const cwd = await $.session.cwd()
151  const turns = await $.session.turns()
152  const run = await $.process.run([
153    'sh',
154    '-c',
155    'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
156      'grep -o \'"permissionMode":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
157    'sh',
158    configDir,
159    await $.session.id(),
160  ])
161  const mode = run.stdout.trim() || undefined
162  await update($, work, w => ({
163    ...w,
164    cwd: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
165    turns,
166    mode: mode ?? w.mode,
167  }))
168}
169
170async function refreshStyle($: EngineInterface) {
171  const style = await $.settings.read({ key: 'outputStyle' })
172  await update($, agent, a => ({ ...a, style: typeof style === 'string' ? style : undefined }))
173}
174
175function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
176  return {
177    context: u.context.percent,
178    tokens: u.context.tokens,
179    window: u.context.window,
180    startedAt: 'startedAt' in u ? (u as SessionUsage).startedAt : undefined,
181    limits: u.rateLimits.map(l => ({
182      kind: l.kind,
183      percent: l.percentUsed,
184      resetsAt: l.resetsAt,
185    })),
186    cost: u.cost?.usd,
187  }
188}
189
190async function refreshUsage($: EngineInterface) {
191  const u = await $.session.usage()
192  await update($, usage, () => toUsage(u))
193}
194
195async function summarizeThread($: EngineInterface) {
196  if (isThreading) return
197  isThreading = true
198  try {
199    const r = await $.model.complete({
200      model: MODEL,
201      maxTokens: 30,
202      system:
203        'You say what a coding conversation is working on right now: the thread its latest prompts share, not only the last step. ' +
204        'Keep the current line unless the work has clearly moved on. ' +
205        'Reply with one line of at most 8 words, sentence case, no quotes, no trailing period.',
206      prompt: [
207        `Current line: ${(await read($, thread)) || '(none)'}`,
208        `Developer prompts, oldest first:\n${prompts.map(p => `- ${p.slice(0, 300)}`).join('\n')}`,
209      ].join('\n\n'),
210    })
211    if (r.isAnswered) await update($, thread, () => oneLine(r.text))
212  } finally {
213    isThreading = false
214  }
215}
216
217async function summarizeNow($: EngineInterface, answer: string, isDone: boolean) {
218  const at = await $.clock.now()
219  if (isSummarizing || (!isDone && at - lastSummaryAt < SUMMARY_INTERVAL_MS)) return
220  isSummarizing = true
221  lastSummaryAt = at
222  try {
223    const r = await $.model.complete({
224      model: MODEL,
225      maxTokens: 40,
226      system:
227        'You watch a coding agent work and report its state in one line of at most 12 words. ' +
228        (isDone ? 'The turn just ended: say what it finished or is waiting for.' : 'Say what it is doing right now.') +
229        ' Present tense, no quotes, no trailing period.',
230      prompt: [
231        `Developer asked: ${prompt.slice(0, 1500)}`,
232        `Recent tool calls:\n${recent.slice(-8).join('\n') || '(none)'}`,
233        `Latest agent text: ${answer.slice(-800) || '(none)'}`,
234      ].join('\n\n'),
235    })
236    if (r.isAnswered) await update($, now, () => oneLine(r.text))
237  } finally {
238    isSummarizing = false
239  }
240}
241
242export const register: Register = on => {
243  on('session.start', async ($, e, next) => {
244    await loadAgent($)
245    void refreshTitle($)
246    void refreshWork($)
247    void refreshGit($)
248    void refreshTasks($)
249    void refreshUsage($)
250    void refreshStyle($)
251    $.clock.every(1000, () => void update($, tick, n => n + 1))
252    return next(e)
253  })
254
255  on('prompt.submit', async ($, e, next) => {
256    prompt = e.text
257    recent = []
258    lastSummaryAt = 0
259    if (e.text.trim() !== '' && !e.text.startsWith('/')) {
260      prompts = [...prompts, e.text].slice(-5)
261      void update($, now, () => 'Reading the request')
262      void summarizeThread($)
263    }
264    queueGit($)
265    void refreshUsage($)
266    void refreshWork($)
267    void $.session.model().then(m => update($, agent, a => ({ ...a, model: prettyModel(m) })))
268    return next(e)
269  })
270
271  on('turn.step', async function* ($, e, next) {
272    const result = yield* next(e)
273    if (e.agentId) return result
274    if (result.toolUses.length > 0) void summarizeNow($, result.answer, false)
275    if (e.effort !== undefined) void update($, agent, a => ({ ...a, effort: String(e.effort) }))
276    const u = result.usage
277    if (u) {
278      const at = await $.clock.now()
279      const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
280      await update($, cache, c => {
281        const wasWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
282        const isMiss = wasWarm && u.cache_read_input_tokens === 0
283        return {
284          hit: total > 0 ? u.cache_read_input_tokens / total : c.hit,
285          lastAt: at,
286          misses: c.misses + (isMiss ? 1 : 0),
287        }
288      })
289    }
290    return result
291  })
292
293  on('tool.call', async ($, e, next) => {
294    if (!e.agentId) {
295      const call = describeCall(e as unknown as Record<string, unknown>)
296      recent = [...recent, call].slice(-20)
297      void update($, tool, () => call)
298    }
299    if (EDIT_TOOLS.has(e.tool)) {
300      const delta = editDelta(e as unknown as Record<string, unknown>)
301      const ran = await next(e)
302      if (!ran.isError)
303        void update($, lines, l => ({
304          added: l.added + delta.added,
305          removed: l.removed + delta.removed,
306        }))
307      queueGit($)
308      return ran
309    }
310    const ran = await next(e)
311    if (GIT_TOOLS.has(e.tool)) queueGit($)
312    if (!e.agentId && TASK_TOOLS.has(e.tool)) void refreshTasks($)
313    return ran
314  })
315
316  on('turn.complete', async ($, e, next) => {
317    const result = await next(e)
318    if (!e.agentId) {
319      await update($, tool, () => '')
320      void summarizeNow($, 'answer' in e ? e.answer : '', true)
321      void refreshTitle($)
322      void refreshWork($)
323      void refreshStyle($)
324    }
325    return result
326  })
327
328  on('session.measure', async ($, e, next) => {
329    await update($, usage, prev => ({
330      ...toUsage(e),
331      startedAt: prev.startedAt,
332    }))
333    return next(e)
334  })
335
336  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337    if (e.props.hasSurvey || (await read($, panesHidden))) return next(e)
338    const els = $.ui.resolve(e)
339    const { Box, Text } = els
340    // The desktop app's font carries no Nerd Font glyphs and lays text out proportionally.
341    const isDesktop = e.surface === 'desktop'
342    const isSplit = e.props.bodyColumns >= 150
343    const below = await next(e)
344    const Svg = 'Svg' in els ? els.Svg : undefined
345    const mark = (key: keyof typeof ICON, color: string) =>
346      isDesktop && Svg ? (
347        <Svg
348          alt={key}
349          width={14}
350          height={14}
351          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>`}
352        />
353      ) : (
354        <Text color={color === 'dim' ? undefined : color} dimColor={color === 'dim'}>
355          {ICON[key]}
356        </Text>
357      )
358    const g = await read($, git)
359    const a: Agent = await read($, agent)
360    const t = await read($, title)
361    const tk = await read($, tasks)
362    const u = await read($, usage)
363    const c = await read($, cache)
364    const ln = await read($, lines)
365    const w = await read($, work)
366    const th = await read($, thread)
367    const nw = await read($, now)
368    const live = e.props.isWorking ? await read($, tool) : ''
369    await read($, tick)
370    const at = await $.clock.now()
371    const done = tk.filter(x => x.status === 'completed').length
372    const isCacheWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
373
374    const LABEL = 9
375    const kv = (
376      name: string,
377      value: RenderChildren,
378      wrap: 'truncate-end' | 'truncate-start' = 'truncate-end',
379      lead?: RenderChildren,
380    ) => (
381      <Box alignItems="center">
382        <Box width={LABEL} flexShrink={0}>
383          <Text color="inactive">{name}</Text>
384        </Box>
385        {lead && (
386          <Box marginRight={1} flexShrink={0}>
387            {lead}
388          </Box>
389        )}
390        <Text wrap={wrap}>{value}</Text>
391      </Box>
392    )
393
394    const gauge = (percent: number, width: number, fill = 'rate_limit_fill') => {
395      if (isDesktop) {
396        const pct = Math.min(100, Math.max(0, percent))
397        return (
398          <Box width={width} height={1} flexShrink={0} alignItems="center">
399            <Box width="100%" height={0.4} backgroundColor="rate_limit_empty" overflow="hidden">
400              <Box width={`${pct}%`} backgroundColor={fill} />
401            </Box>
402          </Box>
403        )
404      }
405      const b = bar(percent, width)
406      const n = b.lastIndexOf('▰') + 1
407      return (
408        <Text>
409          <Text color={fill}>{b.slice(0, n)}</Text>
410          <Text color="rate_limit_empty">{b.slice(n)}</Text>
411        </Text>
412      )
413    }
414
415    const meter = (name: string, percent: number, extra: string) => (
416      <Box flexShrink={0}>
417        <Text dimColor>{name} </Text>
418        {gauge(percent, 6, heat(percent))}
419        <Text bold> {String(Math.round(percent)).padStart(3)}%</Text>
420        {extra !== '' && <Text dimColor> {extra}</Text>}
421      </Box>
422    )
423
424    const label = (name: string) => (
425      <Box width={LABEL} flexShrink={0}>
426        <Text color="inactive">{name}</Text>
427      </Box>
428    )
429    const dot = <Text dimColor> · </Text>
430    const modelParts = [
431      a.account && (
432        <Text>
433          <Text color="suggestion">{a.account}</Text>
434          {a.isWork && (
435            <Text color="error" bold>
436              {' '}
437              ⚠ WORK
438            </Text>
439          )}
440        </Text>
441      ),
442      a.effort && <Text color="warning">{a.effort}</Text>,
443      w.mode && <Text>{w.mode}</Text>,
444      a.style && a.style !== 'default' && <Text>{a.style}</Text>,
445      <Text>{w.turns} turns</Text>,
446    ].filter(Boolean)
447    const current = tk.find(x => x.status === 'in_progress')
448
449    const lead = t.length + th.length + (t !== '' && th !== '' ? 3 : 0)
450    const room = e.props.bodyColumns - 4 - lead
451    const isNowShown = nw !== '' && (lead === 0 || room >= nw.length + 3)
452    const isLiveShown = live !== '' && isNowShown && room >= nw.length + live.length + 6
453
454    return (
455      <Box flexDirection="column">
456        {below}
457        <Text dimColor wrap="truncate-end">
458          {'╌'.repeat(e.props.bodyColumns)}
459        </Text>
460        <Box flexDirection={isSplit ? 'row' : 'column'} paddingX={1}>
461          <Box
462            flexDirection="column"
463            flexShrink={0}
464            width={isSplit ? Math.floor(e.props.bodyColumns / 2) : undefined}
465            paddingRight={isSplit ? 2 : 0}
466          >
467            <Box>
468              {label('repo')}
469              <Box flexShrink={0}>
470                <Text color="claude" bold>
471                  {g?.repo ?? '-'}
472                </Text>
473                {g?.isWorktree && <Text color="suggestion"> worktree</Text>}
474                <Text> </Text>
475              </Box>
476              <Text wrap="truncate-start">{w.cwd}</Text>
477              {u.startedAt !== undefined && (
478                <Box flexShrink={0}>
479                  <Text dimColor> · {duration(at - u.startedAt)}</Text>
480                </Box>
481              )}
482            </Box>
483            {(u.context !== undefined || u.limits.length > 0) && (
484              <Box>
485                {label('usage')}
486                <Box gap={3} overflow="hidden">
487                  {u.context !== undefined &&
488                    meter(
489                      'ctx',
490                      u.context,
491                      u.tokens !== undefined && u.window !== undefined
492                        ? `${Math.round(u.tokens / 1000)}k/${u.window >= 1_000_000 ? `${u.window / 1_000_000}M` : `${Math.round(u.window / 1000)}k`}`
493                        : '',
494                    )}
495                  {u.limits.map(l => meter(LIMIT_LABEL[l.kind] ?? l.kind, l.percent, until(l.resetsAt, at)))}
496                </Box>
497              </Box>
498            )}
499            {kv(
500              'cache',
501              <Text>
502                <Text>{c.hit !== undefined ? `${Math.round(c.hit * 100)}% hit` : '–'}</Text>
503                <Text dimColor>
504                  {' '}
505                  · {isCacheWarm && c.lastAt ? `expires ${until(c.lastAt + CACHE_TTL_MS, at)}` : 'cold'}
506                </Text>
507                {c.misses > 0 && <Text color="error"> · {c.misses} miss</Text>}
508              </Text>,
509              'truncate-end',
510              mark('cache', isCacheWarm ? 'warning' : 'dim'),
511            )}
512            {kv(
513              'spend',
514              <Text>
515                {u.cost !== undefined && <Text color="warning">${u.cost.toFixed(2)}</Text>}
516                {u.cost !== undefined && dot}
517                <Text color="success">+{ln.added}</Text>
518                <Text dimColor>/</Text>
519                <Text color="error">-{ln.removed}</Text>
520                <Text dimColor> lines</Text>
521              </Text>,
522            )}
523          </Box>
524
525          <Box flexDirection="column" flexGrow={1}>
526            {kv(
527              'model',
528              <Text>
529                <Text color="success" bold>
530                  {a.model}
531                </Text>
532                <Text> </Text>
533                {modelParts.map((p, i) => (
534                  <Text>
535                    {i > 0 && dot}
536                    {p}
537                  </Text>
538                ))}
539              </Text>,
540            )}
541            {g &&
542              kv(
543                'branch',
544                <Text>
545                  <Text color="claude" bold>
546                    {g.branch ?? 'detached'}
547                  </Text>
548                  {g.upstream && <Text> {g.upstream}</Text>}
549                  {g.ahead > 0 && <Text color="success"> ↑{g.ahead}</Text>}
550                  {g.behind > 0 && <Text color="error"> ↓{g.behind}</Text>}
551                  {g.upstream && g.ahead === 0 && g.behind === 0 && <Text dimColor> in sync</Text>}
552                  {g.stashes > 0 && dot}
553                  {g.stashes > 0 && <Text color="suggestion">{g.stashes} stashes</Text>}
554                </Text>,
555              )}
556            {g?.last &&
557              kv(
558                'last',
559                <Text>
560                  <Text color="warning">{g.last.hash}</Text>
561                  <Text> {g.last.subject}</Text>
562                  <Text dimColor> · {g.last.age}</Text>
563                </Text>,
564              )}
565            {g &&
566              kv(
567                'changes',
568                g.files.length === 0 ? (
569                  <Text color="success">✓ clean</Text>
570                ) : (
571                  <Text>
572                    {g.added > 0 && <Text color="success">+{g.added} added </Text>}
573                    {g.modified > 0 && <Text color="warning">~{g.modified} modified </Text>}
574                    {g.deleted > 0 && <Text color="error">-{g.deleted} deleted </Text>}
575                    {g.untracked > 0 && <Text color="suggestion">?{g.untracked} untracked</Text>}
576                  </Text>
577                ),
578              )}
579          </Box>
580        </Box>
581        {tk.length > 0 && done < tk.length && (
582          <Box paddingX={1}>
583            {label('tasks')}
584            {gauge((done / tk.length) * 100, 6)}
585            <Text bold>
586              {' '}
587              {done}/{tk.length}
588            </Text>
589            {current && (
590              <Text color="warning" wrap="truncate-end">
591                {'  ▸ '}
592                {current.subject}
593              </Text>
594            )}
595          </Box>
596        )}
597        {(lead > 0 || nw !== '') && (
598          <Box paddingX={1}>
599            <Text wrap="truncate-end">
600              <Text color="claude" bold>
601                ▸{' '}
602              </Text>
603              {t !== '' && (
604                <Text color="suggestion" italic>
605                  {t}
606                </Text>
607              )}
608              {t !== '' && th !== '' && dot}
609              {th !== '' && <Text bold>{th}</Text>}
610              {isNowShown && (
611                <Text dimColor>
612                  {lead > 0 ? ' · ' : ''}
613                  {nw}
614                </Text>
615              )}
616              {isLiveShown && <Text dimColor> ↳ {live}</Text>}
617            </Text>
618          </Box>
619        )}
620      </Box>
621    )
622  })
623}
624
hooks/lib.ts 118 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
108export function describeCall(e: Record<string, unknown>): string {
109  const arg =
110    [e.file_path, e.path, e.notebook_path].find(v => typeof v === 'string')?.toString().split('/').pop() ??
111    [e.description, e.pattern, e.query, e.subject, e.command, e.url, e.skill].find(v => typeof v === 'string')
112  return `${e.tool}${arg ? ` ${String(arg).split('\n')[0]}` : ''}`.slice(0, 80)
113}
114
115export function oneLine(text: string): string {
116  return (text.split('\n').find(l => l.trim() !== '') ?? '').trim().replace(/[.。]$/, '')
117}
118
types/index.d.ts 61 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: {
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      thread: string
56      now: string
57      tool: string
58    }
59  }
60}
61