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

A clean, organized dotfiles repository using symlinks for easy management and deployment.
This dotfiles system uses a unified YAML configuration for symlink management:
symlinks.yml file with OS-specific sectionsdots link updates everything automatically (removes broken symlinks + creates new ones)"common/.local/bin/*": "~/.local/bin")SSH access to GitHub via ProtonPass is required before cloning.
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)
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.common/ (cross-platform) or macos//arch/ (OS-specific), mirroring home directory structuresymlinks.yml in the appropriate sectiondots linksymlinks.ymldots link (broken symlinks are automatically cleaned up)dots link (old symlink removed, new one created)dots link --dry-run --verbose
When you run dots link:
symlinks.yml file for your platform"common/.local/bin/*": "~/.local/bin"--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 operationPlace 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 OS | YAML Section | Description |
|---|---|---|
common | common: | Always processed on all platforms |
macos | macos: | macOS systems |
arch | arch: | 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 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.
hooks/register.tsx 162 lines1import { 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}
162hooks/lib.ts 11 lines1export 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}
11types/index.d.ts 8 lines1export type Line = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 topbar: { title: Line; focus: Line; now: Line; tool: Line }
6 }
7}
8