SLOPSHOPPER

topbar

Band above the prompt: session name, Haiku-summarised focus and current activity

newbandguardpromptmodelprocess
v0.1.0no licenseupdated 2026-10-05nikbrunner/dots/common/.claude/mods/topbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · topbar
› 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 Focus OK Now OK ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Focus OK Now 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 162 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { describeCall, oneLine } from './lib'
5
6const MODEL = 'haiku'
7const SUMMARY_INTERVAL_MS = 10_000
8
9const title = atom({ plugin: 'topbar', key: 'title' } as const, '')
10const focus = atom({ plugin: 'topbar', key: 'focus' } as const, '')
11const now = atom({ plugin: 'topbar', key: 'now' } as const, '')
12const tool = atom({ plugin: 'topbar', key: 'tool' } as const, '')
13
14let configDir = ''
15let prompt = ''
16let recent: string[] = []
17let lastSummaryAt = 0
18let isSummarizing = false
19
20async function refreshTitle($: EngineInterface) {
21  if (!configDir) {
22    const run = await $.process.run(['sh', '-c', 'printf %s "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'])
23    configDir = run.stdout
24  }
25  const run = await $.process.run([
26    'sh',
27    '-c',
28    'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
29      'grep -o \'"\\(customTitle\\|aiTitle\\)":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
30    'sh',
31    configDir,
32    await $.session.id(),
33  ])
34  const found = run.stdout.trim()
35  if (found) await update($, title, () => found)
36}
37
38async function summarizeFocus($: EngineInterface, text: string) {
39  const r = await $.model.complete({
40    model: MODEL,
41    maxTokens: 40,
42    system:
43      'You label what a developer asked a coding agent to do. Reply with one line of at most 10 words, ' +
44      'imperative mood, no quotes, no trailing period.',
45    prompt: text.slice(0, 3000),
46  })
47  if (r.isAnswered) await update($, focus, () => oneLine(r.text))
48}
49
50async function summarizeNow($: EngineInterface, answer: string, isDone: boolean) {
51  const at = await $.clock.now()
52  if (isSummarizing || (!isDone && at - lastSummaryAt < SUMMARY_INTERVAL_MS)) return
53  isSummarizing = true
54  lastSummaryAt = at
55  try {
56    const r = await $.model.complete({
57      model: MODEL,
58      maxTokens: 40,
59      system:
60        'You watch a coding agent work and report its state in one line of at most 12 words. ' +
61        (isDone ? 'The turn just ended: say what it finished or is waiting for.' : 'Say what it is doing right now.') +
62        ' Present tense, no quotes, no trailing period.',
63      prompt: [
64        `Developer asked: ${prompt.slice(0, 1500)}`,
65        `Recent tool calls:\n${recent.slice(-8).join('\n') || '(none)'}`,
66        `Latest agent text: ${answer.slice(-800) || '(none)'}`,
67      ].join('\n\n'),
68    })
69    if (r.isAnswered) await update($, now, () => oneLine(r.text))
70  } finally {
71    isSummarizing = false
72  }
73}
74
75export const register: Register = on => {
76  on('session.start', async ($, e, next) => {
77    void refreshTitle($)
78    return next(e)
79  })
80
81  on('prompt.submit', async ($, e, next) => {
82    prompt = e.text
83    recent = []
84    lastSummaryAt = 0
85    if (e.text.trim() !== '' && !e.text.startsWith('/')) {
86      void update($, now, () => 'Reading the request')
87      void summarizeFocus($, e.text)
88    }
89    return next(e)
90  })
91
92  on('tool.call', async ($, e, next) => {
93    if (e.agentId) return next(e)
94    const call = describeCall(e as unknown as Record<string, unknown>)
95    recent = [...recent, call].slice(-20)
96    await update($, tool, () => call)
97    return next(e)
98  })
99
100  on('turn.step', async function* ($, e, next) {
101    const result = yield* next(e)
102    if (!e.agentId && result.toolUses.length > 0) void summarizeNow($, result.answer, false)
103    return result
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    const result = await next(e)
108    if (!e.agentId) {
109      await update($, tool, () => '')
110      void summarizeNow($, 'answer' in e ? e.answer : '', true)
111      void refreshTitle($)
112    }
113    return result
114  })
115
116  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117    if (e.props.hasSurvey) return next(e)
118    const t = await read($, title)
119    const f = await read($, focus)
120    const n = await read($, now)
121    const live = await read($, tool)
122    if (t === '' && f === '' && n === '') return next(e)
123
124    const { Box, Text } = $.ui.resolve(e)
125    const rule = '╌'.repeat(Math.max(10, (e.viewport?.columns ?? 80) - 2))
126
127    return (
128      <Box flexDirection="column" paddingX={1}>
129        {t !== '' && (
130          <Text color="suggestion" bold wrap="truncate-end">
131            ▸ {t}
132          </Text>
133        )}
134        {f !== '' && (
135          <Text wrap="truncate-end">
136            <Text color="warning" bold>
137              Focus{' '}
138            </Text>
139            <Text>{f}</Text>
140          </Text>
141        )}
142        {n !== '' && (
143          <Text wrap="truncate-end">
144            <Text color="success" bold>
145              Now{'   '}
146            </Text>
147            <Text>{n}</Text>
148          </Text>
149        )}
150        {e.props.isWorking && live !== '' && (
151          <Text dimColor wrap="truncate-end">
152            {'      '}↳ {live}
153          </Text>
154        )}
155        <Text dimColor wrap="truncate-end">
156          {rule}
157        </Text>
158      </Box>
159    )
160  })
161}
162
hooks/lib.ts 11 lines
1export function describeCall(e: Record<string, unknown>): string {
2  const arg =
3    [e.file_path, e.path, e.notebook_path].find(v => typeof v === 'string')?.toString().split('/').pop() ??
4    [e.description, e.pattern, e.query, e.subject, e.command, e.url, e.skill].find(v => typeof v === 'string')
5  return `${e.tool}${arg ? ` ${String(arg).split('\n')[0]}` : ''}`.slice(0, 80)
6}
7
8export function oneLine(text: string): string {
9  return (text.split('\n').find(l => l.trim() !== '') ?? '').trim().replace(/[.。]$/, '')
10}
11
types/index.d.ts 8 lines
1export type Line = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    topbar: { title: Line; focus: Line; now: Line; tool: Line }
6  }
7}
8