Dotfiles – (neo)vim, tmux and zsh configuration

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.
brew install chezmoi
chezmoi init --apply rcliao
That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:
| Prompt | Why it is per-machine |
|---|---|
| Full name | git author name |
| Git email | so a work laptop and a personal box can share this repo without cross-signing commits |
| Ghost hooks | see Ghost below |
Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.
The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.
chezmoi diff # what would change
chezmoi apply # everything
chezmoi apply ~/.zshrc # just one target
chezmoi add ~/.tmux.conf # pull a local edit back into the repo
chezmoi update # git pull + apply
This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.
Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).
Terminals — ghostty.
Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.
Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.
Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.
Window management — aerospace.
Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.
Other — bat, yazi.
The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.
To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.
Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:
brew trust --cask nikitabobko/tap/aerospace # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace
Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.
This repo is public, so nothing employer-specific goes in it.
hooks/register.tsx 299 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Job } from '../types'
5import {
6 bandRows,
7 duration,
8 lastLineOf,
9 parseBackgroundStart,
10 parseProgress,
11 parseTaskEnds,
12 progressText,
13 pushHistory,
14 shortLabel,
15 statusLine,
16 trimJobs,
17 visibleLines,
18} from './progress.ts'
19import type { TaskEnd } from './progress.ts'
20
21const PANE = 'jobs'
22const TITLE = 'Background jobs'
23const POLL_MS = 5_000
24const TAIL_BYTES = '16384'
25
26const REPORT_RULE =
27 'When you start a long-running command with run_in_background and you control its output (a script or loop you write), have it print progress lines `::progress <done>/<total> <what>` (or `::progress <n>%`), and `::status <text>` on a phase change; the user sees them live in the jobs footer and pane.'
28
29const listAtom = atom({ plugin: 'jobs', key: 'list' } as const, [] as Job[])
30const lastPromptAtom = atom({ plugin: 'jobs', key: 'lastPromptAt' } as const, 0)
31
32const finish = (jobs: readonly Job[], ends: readonly TaskEnd[], now: number) => {
33 const done: Job[] = []
34 const list = jobs.map(job => {
35 const end = ends.find(x => x.id === job.id)
36 if (end === undefined || job.status !== 'running') return job
37 const next: Job = { ...job, status: end.status, endedAt: now }
38 if (end.exitCode !== undefined) next.exitCode = end.exitCode
39 done.push(next)
40 return next
41 })
42 return { list: trimJobs(list), done }
43}
44
45// The rows live in the band above the prompt (info, not a ⚠ pinned status); redraw it.
46async function refreshStatus($: EngineInterface): Promise<void> {
47 $.ui.invalidate('ui.render')
48}
49
50async function endJobs($: EngineInterface, ends: readonly TaskEnd[]): Promise<void> {
51 if (ends.length === 0) return
52 const now = await $.clock.now()
53 let done: Job[] = []
54 await update($, listAtom, prev => {
55 const out = finish(prev ?? [], ends, now)
56 done = out.done
57 return out.list
58 })
59 // Only what you'd otherwise miss gets a toast; a completed job is a dim band row.
60 for (const job of done) if (job.status !== 'completed') $.ui.toast(`${job.label}: ${progressText(job, now)}`)
61 await refreshStatus($)
62}
63
64async function addJob($: EngineInterface, job: Job): Promise<void> {
65 await update($, listAtom, prev => trimJobs([...(prev ?? []).filter(j => j.id !== job.id), job]))
66 await refreshStatus($)
67}
68
69// One pass over running jobs: tail logs that grew, ask the engine about agents.
70async function poll($: EngineInterface): Promise<void> {
71 const jobs = await read($, listAtom)
72 const running = jobs.filter(j => j.status === 'running')
73 // Still refresh when idle, so a finished job leaves the status line once it is no longer recent.
74 if (running.length === 0) return refreshStatus($)
75 const now = await $.clock.now()
76 const changes = new Map<string, Partial<Job>>()
77
78 for (const job of running) {
79 if (job.file === undefined) continue
80 try {
81 const stat = await $.fs.stat(job.file)
82 if (stat.size === job.size) continue
83 const tail = await $.process.run(['tail', '-c', TAIL_BYTES, job.file], { timeoutMs: 3_000 })
84 const lines = visibleLines(tail.stdout)
85 const progress = parseProgress(lines)
86 const change: Partial<Job> = { size: stat.size, changedAt: now, history: pushHistory(job.history, now, progress?.pct) }
87 const last = lastLineOf(lines)
88 if (last !== undefined) change.lastLine = last
89 if (progress !== undefined) change.progress = progress
90 changes.set(job.id, change)
91 } catch {
92 // A log that vanished or cannot be read leaves the job as it was.
93 }
94 }
95
96 const ends: TaskEnd[] = []
97 if (running.some(j => j.kind === 'agent')) {
98 const agents = await $.agent.list()
99 for (const job of running.filter(j => j.kind === 'agent')) {
100 const agent = agents.find(a => a.id === job.id)
101 if (agent === undefined || agent.status === 'running') continue
102 ends.push({ id: job.id, status: agent.status === 'completed' ? 'completed' : agent.status === 'killed' ? 'killed' : 'failed' })
103 }
104 }
105
106 if (changes.size > 0) {
107 await update($, listAtom, prev => (prev ?? []).map(j => ({ ...j, ...(changes.get(j.id) ?? {}) })))
108 }
109 await endJobs($, ends)
110 await refreshStatus($)
111}
112
113export const register: Register = on => {
114 on('session.start', async ($, e, next) => {
115 await $.command.register({
116 name: 'jobs',
117 description: 'Background job progress (open pane; watch <file> [label]; unwatch <label>; clear)',
118 argumentHint: '[watch <file> [label] | unwatch <label> | clear]',
119 immediate: true,
120 })
121 $.clock.every(POLL_MS, () => {
122 void poll($)
123 })
124 // Earlier versions pinned a status line; a reload must take it down.
125 $.ui.status(undefined)
126 return next(e)
127 })
128
129 on('tool.call', async ($, e, next) => {
130 const ran = await next(e)
131 if (ran.deny !== undefined || ran.isError === true) return ran
132 const now = await $.clock.now()
133
134 if (e.tool === 'Bash') {
135 const started = parseBackgroundStart(ran.text ?? '')
136 if (started !== undefined) {
137 const job: Job = {
138 id: started.id,
139 kind: 'shell',
140 label: shortLabel(e.description ?? e.command),
141 startedAt: now,
142 status: 'running',
143 history: [],
144 }
145 if (started.file !== undefined) job.file = started.file
146 await addJob($, job)
147 }
148 } else if (e.tool === 'Agent') {
149 const result = ran.result as { status?: string; agentId?: string; description?: string } | undefined
150 if (result?.status === 'async_launched' && result.agentId !== undefined) {
151 await addJob($, {
152 id: result.agentId,
153 kind: 'agent',
154 label: shortLabel(`agent: ${result.description ?? 'background'}`),
155 startedAt: now,
156 status: 'running',
157 history: [],
158 })
159 }
160 } else if (e.tool === 'TaskStop') {
161 const id = e.task_id ?? e.shell_id
162 if (id !== undefined) await endJobs($, [{ id, status: 'killed' }])
163 }
164 return ran
165 })
166
167 // Completion: the engine appends a <task-notification> row (and may deliver one) when a task ends.
168 on('session.append', async ($, e, next) => {
169 const text = JSON.stringify(e.message.content ?? '')
170 if (text.includes('task-notification')) await endJobs($, parseTaskEnds(text.replace(/\\n/g, '\n')))
171 return next(e)
172 })
173
174 // Failed/killed rows stay in the band until the person's next prompt.
175 on('prompt.submit', async ($, e, next) => {
176 const now = await $.clock.now()
177 await update($, lastPromptAtom, () => now)
178 return next(e)
179 })
180
181 on('session.receive', async ($, e, next) => {
182 if (e.text.includes('task-notification')) await endJobs($, parseTaskEnds(e.text))
183 return next(e)
184 })
185
186 on('command.run', { command: 'jobs' }, async ($, e) => {
187 const [verb, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
188 const now = await $.clock.now()
189
190 if (verb === 'watch') {
191 const [file, ...label] = rest
192 if (file === undefined) return { text: 'Usage: /jobs watch <file> [label]' }
193 if (!(await $.fs.exists(file))) return { text: `No such file: ${file}` }
194 await addJob($, {
195 id: `watch:${file}`,
196 kind: 'watch',
197 label: shortLabel(label.length > 0 ? label.join(' ') : (file.split('/').pop() ?? file)),
198 file,
199 startedAt: now,
200 status: 'running',
201 history: [],
202 })
203 void poll($)
204 return { text: `Watching ${file}.` }
205 }
206 if (verb === 'unwatch') {
207 const key = rest.join(' ')
208 await update($, listAtom, prev => (prev ?? []).filter(j => !(j.kind === 'watch' && (j.label === key || j.file === key))))
209 await refreshStatus($)
210 return { text: `Stopped watching ${key}.` }
211 }
212 if (verb === 'clear') {
213 await update($, listAtom, prev => (prev ?? []).filter(j => j.status === 'running'))
214 await refreshStatus($)
215 return { text: 'Cleared finished jobs.' }
216 }
217
218 await $.ui.open({ id: PANE, title: TITLE })
219 const jobs = await read($, listAtom)
220 if (jobs.length === 0) return { text: 'No background jobs.' }
221 return { text: jobs.map(j => `${j.status === 'running' ? 'run ' : 'end '} ${j.label} ${duration(now - j.startedAt)} ${progressText(j, now)}`).join('\n') }
222 })
223
224 // Without a prompt rule, a model-facing convention goes unused (see run-ledger's eval: 0/5 → 3/3).
225 on('prompt.compose', async ($, e, next) => {
226 const composed = await next(e)
227 if (!e.tools.includes('Bash')) return composed
228 return { sections: [...composed.sections, { id: 'jobs:progress', text: REPORT_RULE, scope: 'session' }] }
229 })
230
231 // The band above the prompt always has room (the footer tail is dropped when the footer is full).
232 // Draw after whatever is beneath, so run-ledger's line stays on top in either load order.
233 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
234 const below = await next(e)
235 if (e.props.hasSurvey) return below
236 const rows = bandRows(await read($, listAtom), await $.clock.now(), Math.max(20, e.props.bodyColumns - 1), await read($, lastPromptAtom))
237 if (rows.length === 0) return below
238 const { Box, Text } = $.ui.resolve(e)
239 return (
240 <Box flexDirection="column">
241 {below}
242 {rows.map(row =>
243 row.tone === 'failed' ? (
244 <Text key={row.key} color="red">{row.text}</Text>
245 ) : (
246 <Text key={row.key} dimColor={row.tone === 'done'}>{row.text}</Text>
247 ),
248 )}
249 </Box>
250 )
251 })
252
253 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
254 const { Box, Text, Button } = $.ui.resolve(e)
255 const jobs = await read($, listAtom)
256 const now = await $.clock.now()
257 const width = Math.max(20, e.props.bodyColumns - 2)
258 const ordered = [...jobs.filter(j => j.status === 'running'), ...jobs.filter(j => j.status !== 'running').reverse()]
259
260 if (ordered.length === 0) {
261 return (
262 <Box flexDirection="column">
263 <Text dimColor>No background jobs.</Text>
264 <Text dimColor>Started with run_in_background, a background agent, or /jobs watch <file>.</Text>
265 </Box>
266 )
267 }
268
269 return (
270 <Box flexDirection="column">
271 {ordered.map(job => (
272 <Box key={job.id} flexDirection="column" marginBottom={1}>
273 <Box flexDirection="row">
274 <Text bold={job.status === 'running'} dimColor={job.status !== 'running'} color={job.status === 'failed' ? 'red' : undefined} wrap="truncate-end">
275 {`${job.label} ${duration((job.endedAt ?? now) - job.startedAt)} ${progressText(job, now)} `}
276 </Text>
277 {job.status === 'running' && job.kind !== 'watch' ? (
278 <Button key={`stop:${job.id}`} label="stop" onPress={async () => {
279 await $.tool.call({ tool: 'TaskStop', task_id: job.id })
280 await endJobs($, [{ id: job.id, status: 'killed' }])
281 }} />
282 ) : null}
283 {job.kind === 'watch' && job.status === 'running' ? (
284 <Button key={`unwatch:${job.id}`} label="unwatch" onPress={async () => {
285 await update($, listAtom, prev => (prev ?? []).filter(j => j.id !== job.id))
286 await refreshStatus($)
287 }} />
288 ) : null}
289 </Box>
290 {job.lastLine === undefined ? null : (
291 <Text dimColor wrap="truncate-end">{job.lastLine.slice(0, width)}</Text>
292 )}
293 </Box>
294 ))}
295 </Box>
296 )
297 })
298}
299hooks/progress.ts 214 lines1// Pure helpers: parse progress out of a log tail, read the engine's background texts, format. No `$`.
2import type { Job, JobProgress, JobStatus } from '../types'
3
4export const MAX_FINISHED = 8
5// A finished job stays in the status line this long.
6export const RECENT_MS = 120_000
7// Output that has not grown for this long is called out as quiet (possible stall).
8export const QUIET_MS = 180_000
9
10// eslint-disable-next-line no-control-regex
11const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g
12
13// A progress bar redraws one line with \r: the visible line is the segment after the last \r.
14export const visibleLines = (tail: string): string[] =>
15 tail
16 .replace(ANSI, '')
17 .split('\n')
18 .map(line => (line.split('\r').filter(s => s.trim() !== '').pop() ?? '').trim())
19 .filter(line => line !== '')
20
21const UNIT: Record<string, number> = { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, kib: 1024, mib: 1024 ** 2, gib: 1024 ** 3 }
22const SIZE = /([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\s*(?:\/|of)\s*([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB)\b/i
23const PERCENT = /(?<![\d.])(\d{1,3}(?:\.\d+)?)\s?%/
24const COUNT = /(?<![\d./:-])(\d+)\s*(?:\/|\bof\b)\s*(\d+)(?![\d./:-])/
25
26const clampPct = (n: number) => Math.max(0, Math.min(100, n))
27
28// Lines a job prints to report on itself: `::progress 12/40 uploading`, `::progress 64%`, `::status waiting on CI`.
29const MARK_PROGRESS = /^::progress\s+(\S+)\s*(.*)$/
30const MARK_STATUS = /^::status\s+(.+)$/
31export const isMarker = (line: string): boolean => line.startsWith('::progress ') || line.startsWith('::status ')
32
33const specPct = (spec: string): number | undefined => {
34 const pct = /^(\d{1,3}(?:\.\d+)?)%$/.exec(spec)
35 if (pct?.[1] !== undefined) return clampPct(Number(pct[1]))
36 const count = /^(\d+)\/(\d+)$/.exec(spec)
37 if (count && Number(count[2]) > 0) return clampPct((Number(count[1]) / Number(count[2])) * 100)
38 const size = /^([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)\/([\d.]+)(B|KB|MB|GB|TB|KiB|MiB|GiB)$/i.exec(spec)
39 if (size) {
40 const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
41 const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
42 if (total > 0) return clampPct((done / total) * 100)
43 }
44 return undefined
45}
46
47// The job's own report wins over anything guessed: the newest ::progress, plus a newer ::status.
48const parseMarkers = (lines: readonly string[]): JobProgress | undefined => {
49 let progress: { at: number; spec: string; msg: string } | undefined
50 let status: { at: number; msg: string } | undefined
51 for (let i = lines.length - 1; i >= 0 && (progress === undefined || status === undefined); i--) {
52 const line = lines[i] ?? ''
53 const p = progress === undefined ? MARK_PROGRESS.exec(line) : null
54 if (p) progress = { at: i, spec: p[1] ?? '', msg: (p[2] ?? '').trim() }
55 const s = status === undefined ? MARK_STATUS.exec(line) : null
56 if (s) status = { at: i, msg: (s[1] ?? '').trim() }
57 }
58 if (progress === undefined && status === undefined) return undefined
59 const msg = status !== undefined && (progress === undefined || status.at > progress.at) ? status.msg : progress?.msg ?? ''
60 const pct = progress === undefined ? undefined : specPct(progress.spec)
61 const text = [progress?.spec, msg].filter(Boolean).join(' ')
62 return pct === undefined ? { text } : { pct, text }
63}
64
65// Newest line first; within a line an explicit % wins, then byte pairs, then N/M counts.
66export const parseProgress = (lines: readonly string[]): JobProgress | undefined => {
67 const marked = parseMarkers(lines)
68 if (marked !== undefined) return marked
69 for (let i = lines.length - 1; i >= Math.max(0, lines.length - 20); i--) {
70 const line = lines[i] ?? ''
71 const pct = PERCENT.exec(line)
72 if (pct?.[1] !== undefined && Number(pct[1]) <= 100) return { pct: clampPct(Number(pct[1])), text: `${Math.round(Number(pct[1]))}%` }
73 const size = SIZE.exec(line)
74 if (size) {
75 const done = Number(size[1]) * (UNIT[(size[2] ?? 'b').toLowerCase()] ?? 1)
76 const total = Number(size[3]) * (UNIT[(size[4] ?? 'b').toLowerCase()] ?? 1)
77 if (total > 0 && done <= total) return { pct: clampPct((done / total) * 100), text: `${size[1]} ${size[2]} / ${size[3]} ${size[4]}` }
78 }
79 const count = COUNT.exec(line)
80 if (count) {
81 const done = Number(count[1])
82 const total = Number(count[2])
83 if (total > 1 && done <= total) return { pct: clampPct((done / total) * 100), text: `${done}/${total}` }
84 }
85 }
86 return undefined
87}
88
89export const lastLineOf = (lines: readonly string[]): string | undefined => {
90 const line = lines.filter(l => !isMarker(l)).pop()
91 if (line === undefined) return undefined
92 return line.length > 160 ? `${line.slice(0, 159)}…` : line
93}
94
95// "Command running in background with ID: bpvctn69i. Output is being written to: /…/bpvctn69i.output."
96export const parseBackgroundStart = (text: string): { id: string; file?: string } | undefined => {
97 const id = /running in background with ID: ([\w-]+)/.exec(text)?.[1]
98 if (id === undefined) return undefined
99 const file = /Output is being written to: (\S+?\.output)\b/.exec(text)?.[1]
100 return file === undefined ? { id } : { id, file }
101}
102
103export type TaskEnd = { id: string; status: JobStatus; exitCode?: number }
104
105// <task-notification><task-id>…</task-id>…<status>completed</status><summary>… (exit code 0)</summary>
106export const parseTaskEnds = (text: string): TaskEnd[] => {
107 const ends: TaskEnd[] = []
108 for (const block of text.split('<task-notification>').slice(1)) {
109 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
110 const raw = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1]
111 if (id === undefined || raw === undefined) continue
112 const code = /exit code (-?\d+)/.exec(block)?.[1]
113 const status: JobStatus =
114 raw === 'killed' || raw === 'stopped' ? 'killed' : raw === 'failed' || (code !== undefined && code !== '0') ? 'failed' : 'completed'
115 ends.push(code === undefined ? { id, status } : { id, status, exitCode: Number(code) })
116 }
117 return ends
118}
119
120export const duration = (ms: number): string => {
121 const s = Math.max(0, Math.round(ms / 1000))
122 if (s < 60) return `${s}s`
123 if (s < 600) return s % 60 === 0 ? `${s / 60}m` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
124 const m = Math.round(s / 60)
125 if (m < 60) return `${m}m`
126 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
127}
128
129// Rate over the last few minutes of samples; undefined until there is a measurable rise.
130export const eta = (history: Job['history'], now: number): string | undefined => {
131 const recent = history.filter(p => now - p.at <= 600_000)
132 const first = recent[0]
133 const last = recent[recent.length - 1]
134 if (first === undefined || last === undefined || last.pct <= first.pct || last.at <= first.at) return undefined
135 const perMs = (last.pct - first.pct) / (last.at - first.at)
136 return `~${duration((100 - last.pct) / perMs)}`
137}
138
139export const pushHistory = (history: Job['history'], at: number, pct: number | undefined): Job['history'] => {
140 if (pct === undefined) return history
141 const prev = history[history.length - 1]
142 if (prev !== undefined && pct < prev.pct) return [{ at, pct }] // restarted, or a new phase
143 if (prev !== undefined && pct === prev.pct) return history
144 return [...history, { at, pct }].slice(-30)
145}
146
147export const progressText = (job: Job, now: number): string => {
148 if (job.status !== 'running') {
149 const took = duration((job.endedAt ?? now) - job.startedAt)
150 const code = job.exitCode !== undefined && job.exitCode !== 0 ? ` (exit ${job.exitCode})` : ''
151 return `${job.status === 'completed' ? 'done' : job.status}${code} in ${took}`
152 }
153 const parts: string[] = []
154 if (job.progress) parts.push(job.progress.text)
155 const left = job.progress?.pct === undefined ? undefined : eta(job.history, now)
156 if (left !== undefined) parts.push(left)
157 if (job.changedAt !== undefined && now - job.changedAt >= QUIET_MS) parts.push(`quiet ${duration(now - job.changedAt)}`)
158 return parts.join(' ')
159}
160
161export const statusLine = (jobs: readonly Job[], now: number): string | undefined => {
162 const running = jobs.filter(j => j.status === 'running')
163 const shown = running.slice(0, 3).map(j => [j.label, progressText(j, now)].filter(Boolean).join(' '))
164 if (running.length > 3) shown.push(`+${running.length - 3}`)
165 if (shown.length === 0) {
166 const recent = jobs.filter(j => j.status !== 'running' && now - (j.endedAt ?? 0) <= RECENT_MS)
167 for (const j of recent.slice(-2)) shown.push(`${j.label} ${progressText(j, now)}`)
168 }
169 // The engine already prefixes a mod's status with its name.
170 return shown.length === 0 ? undefined : shown.join(' · ')
171}
172
173export const shortLabel = (text: string): string => {
174 const one = text.trim().split('\n')[0] ?? ''
175 return one.length > 28 ? `${one.slice(0, 27)}…` : one
176}
177
178// Keep every running job and the most recent finished ones.
179export const trimJobs = (jobs: readonly Job[]): Job[] => {
180 const finished = jobs.filter(j => j.status !== 'running')
181 const keep = new Set(finished.slice(-MAX_FINISHED))
182 return jobs.filter(j => j.status === 'running' || keep.has(j))
183}
184
185export type BandRow = { key: string; text: string; tone: 'running' | 'done' | 'failed' }
186
187const pad = (text: string, n: number) => (text.length >= n ? text.slice(0, n) : text + ' '.repeat(n - text.length))
188
189// One row per running job, then the recently finished; aligned columns, cut to the band's width.
190// A completed job shows for RECENT_MS; a failed or killed one stays until the person's next prompt.
191export const isShownFinished = (job: Job, now: number, lastPromptAt: number): boolean =>
192 job.status === 'completed'
193 ? now - (job.endedAt ?? 0) <= RECENT_MS
194 : job.status !== 'running' && ((job.endedAt ?? 0) > lastPromptAt || now - (job.endedAt ?? 0) <= RECENT_MS)
195
196export const bandRows = (jobs: readonly Job[], now: number, columns: number, lastPromptAt = 0, max = 4): BandRow[] => {
197 const running = jobs.filter(j => j.status === 'running')
198 const recent = jobs.filter(j => isShownFinished(j, now, lastPromptAt)).reverse()
199 const all = [...running, ...recent]
200 const rows: BandRow[] = all.slice(0, max).map(job => {
201 const word = job.status === 'running' ? 'run ' : job.status === 'completed' ? 'done' : job.status === 'failed' ? 'fail' : 'kill'
202 const elapsed = duration((job.endedAt ?? now) - job.startedAt)
203 const detail = job.status === 'running' ? progressText(job, now) : job.exitCode !== undefined && job.exitCode !== 0 ? `exit ${job.exitCode}` : ''
204 const text = `${word} ${pad(job.label, 28)} ${pad(elapsed, 6)} ${detail}`.trimEnd()
205 return {
206 key: job.id,
207 text: text.length > columns ? `${text.slice(0, Math.max(0, columns - 1))}…` : text,
208 tone: job.status === 'running' ? 'running' : job.status === 'completed' ? 'done' : 'failed',
209 }
210 })
211 if (all.length > max) rows.push({ key: 'more', text: ` +${all.length - max} more · /jobs`, tone: 'done' })
212 return rows
213}
214types/index.d.ts 30 lines1export type JobKind = 'shell' | 'agent' | 'watch'
2
3export type JobStatus = 'running' | 'completed' | 'failed' | 'killed'
4
5export type JobProgress = { pct?: number; text: string }
6
7export type Job = {
8 id: string
9 kind: JobKind
10 label: string
11 file?: string
12 startedAt: number
13 status: JobStatus
14 endedAt?: number
15 exitCode?: number
16 progress?: JobProgress
17 lastLine?: string
18 /** when the log last grew, for "quiet 4m" stall hints */
19 changedAt?: number
20 size?: number
21 /** (time, pct) points for the ETA */
22 history: { at: number; pct: number }[]
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 jobs: { list: Job[]; lastPromptAt: number }
28 }
29}
30