Ett band ovanför prompten: varning innan promptcachen går kall, när settings.json slutat vara en länk till dotfiles, och (pausad) en översikt över alla…


Personal configuration for Arch Linux with niri, and macOS. Managed with GNU Stow.
Every visible top-level directory is a stow package, and each one documents itself. Anything that is not a package lives out of the way: repo assets under .github/, and material kept for reference but never symlinked under reference/.
| Package | Platform | |
|---|---|---|
| bin | Helper scripts → ~/.local/bin | both |
| claude | Claude Code settings, skills, statusline | both |
| fastfetch | Two views: environment, and ffhw for hardware | both |
| ghostty | Terminal emulator, incl. cursor shader | both |
| lsd | ls replacement, follows the terminal palette | both |
| mpv | Video player: floating window, sized by the clip | both |
| niri | Scrolling tiling compositor | Linux |
| noctalia | Desktop shell for niri: bar, notifications, OSD, lock screen, palette | Linux |
| nvim | Neovim (LazyVim) + keybind cheatsheet | both |
| rbw | Bitwarden and Vaultwarden on Mod+P: copy a password without the clipboard history | Linux |
| rofi | Launcher on Mod+Space and the card Mod+P draws in, styled after the login screen | Linux |
| spotify | Forces the client onto native Wayland | Linux |
| tmux | Session persistence over SSH, not local splits | both |
| wave-xlr | Elgato Wave XLR: daemon unit + mic silence watchdog | Linux |
| xremap | super → ctrl per app, below the compositor | Linux |
| zsh | Shell, prompt, aliases | both |
curl -fsSL https://raw.githubusercontent.com/eehnsio/dotfiles/main/bootstrap.sh | bash
bootstrap.sh deliberately offers no choices: it installs git and GNU Stow, clones the repo and hands over. Which packages get linked is up to you — ./install or STOW_FOLDERS="nvim,zsh" ./install. Bootstrap links nothing on your behalf, which matters on a machine where half the packages are wrong.
Secrets belong in no repo — not this public one, not a private one. Machine secrets live in Vaultwarden and are fetched with
vw-render; public keys are distributed bybaseline.ymlin the homelab repo.
sudo pacman -S stow # or: brew install stow
git clone <repo-url> ~/Developer/dotfiles
cd ~/Developer/dotfiles
./install
install stows every package, picking the Linux-only ones by uname. Its package list is deliberately explicit rather than "every directory" — reference/ and .github/ sit at the top level too and must never be stowed.
Install a subset instead:
STOW_FOLDERS="nvim,zsh" ./install
Package READMEs are not symlinked into $HOME. Stow ignores top-level README.* on its own — do not add a .stow-local-ignore to "help" it. A local ignore file replaces stow's built-in list rather than extending it, which is exactly how ~/KEYBINDS.md once ended up as a symlink into this repo.
.DS_Store is not on that built-in list either, and it is the more dangerous case. Finder scatters them through the packages on macOS; git ignores them but stow does not, and one stray file aborts the entire package. Since install unstows before it stows, that abort leaves the package unlinked — ~/.zshrc simply disappears. install therefore deletes them before stowing, rather than reaching for a local ignore file. Linux never sees this.
Nothing needs cherry-picking between them. install picks the Linux-only packages by uname, so a niri commit landing on the Mac is just inert files in a directory nothing reads. Pull everything, run ./install, done.
The one thing that does fight you is lockfiles. lazy-lock.json and plugins.lock.json are rewritten by their own tools at different times on each machine, so they conflict without anyone having made a decision. .gitattributes marks them merge=ours and install registers the driver, so each machine keeps its own copy and the pull goes through.
Note that enabledPlugins in claude/.claude/settings.json also churns, but that one is left alone on purpose — the other ten keys in that file are genuine shared preferences that should propagate.
Anything that differs per machine — monitors, resolutions, scaling, font size — lives in .local files that are gitignored but sit inside the packages, so stow symlinks them like everything else. The content stays local while the path stays identical everywhere.
| File | Seeded from | Loaded by |
|---|---|---|
niri/.config/niri/local.kdl | local.kdl.example | include "local.kdl" |
ghostty/.config/ghostty/config.local | config.local.example | config-file = ?config.local |
zsh/.zshrc.local | — | sourced if present |
./install copies each .example to its real name when missing. That matters for niri: it refuses to start when an include target does not exist, so the file has to be there before the first launch. Ghostty is relaxed — the ? prefix makes the include optional.
Both .local files load last, so they override whatever the tracked config set.
One accent, #7fc8ff, shared by niri's focus ring, the shell, the terminal listing and the statusline. Surfaces are near-neutral greys on purpose: the blue is what points, so nothing else should compete with it.
Ghostty carries the palette. lsd, the zsh prompt and the Claude statusline express all their colour as ANSI indices and own no palette of their own, so ghostty/.config/ghostty/themes/drivis decides how three tools look. The indices there are assigned by role, not by name — 6 is the accent, 4 is directories and branches, 8 is metadata everywhere — which is why an auto-generated palette was not usable: dms dank16 put a dark blue at index 5, where magenta carries the prompt arrow and the model name.
Index 8 matters most. Nearly every character in a listing is metadata, and the statusline rests on the same index. It sits deliberately lighter than Tokyo Night's #414868, which turned out unreadable once Claude Code's own dimming was applied on top.
Warm colours are reserved. Yellow and red appear only when something is wrong: a git conflict, a context window running out. Never as decoration.
$HOME~/.config./install after pulling; it unstows before stowing, so it is safe to repeathooks/register.tsx 489 lines1import type { ElementConstructor, EngineInterface, Register, TextProps, Timer } from 'claude-code'
2
3import type { CacheNotice, SessionNotice, SessionRow, SettingsDrift } from '../types'
4
5// Ghostty har ingen överblick över flikar och splits, så den här modden ger en
6// åtminstone för Claude-sessionerna, som herdrs: ett band ovanför prompten med
7// en rad per grupp. Läget bärs av färgen på namnet, åldern står direkt efter.
8// En tabell med jämna kolumner prövades och var svårare att läsa: det längsta
9// namnet satte bredden och åldrarna hamnade långt från sina namn.
10//
11// ◷ promptcachen går kall om 3 min ▲ api-4f behöver ditt svar · dialog open
12// jobbar devops-5b nyss
13// väntar Claude Mods 16m web-9c app 52m infra-39 1h vilande blog-74 app 2d
14//
15// Det fanns en ruta och en dockad sidopanel också. Bandet är lika brett som
16// terminalen, så rutan lämnade död yta bredvid sig; panelen fick dockans egen
17// bakgrund och kan bara ligga till höger. Raderna vann.
18//
19// Varje session skriver ~/.claude/sessions/<pid>.json med namn, katalog och
20// läge; filerna ligger kvar när en process dör, så pid:en kollas mot ps innan
21// raden visas.
22//
23// Samma pollning märker när en ANNAN session ställer en fråga eller jobbar
24// klart och lägger det som en notis överst i bandet (sessions.notifyIdle).
25// Först var det en toast, men den hamnar i ett hörn där man inte tittar,
26// glider in med en animation och har en egen svart kant.
27//
28// Promptcachen varnar i samma notisrad: tre minuter innan den går kall, med
29// ljud, och sedan att den är kall tills nästa prompt. Det var en egen mod
30// (cache-alarm), men bara en mod kan rita bandet, så de bor ihop här.
31//
32// Av samma skäl bor kollen av settings.json här: att den fortfarande är en
33// stow-länk in i dotfiles. Den var en SessionStart-hook.
34const POLL_MS = 5_000
35// Klar längre än så räknas som vilande och får en egen rad.
36const DORMANT_MS = 12 * 3600_000
37// En klar-notis står kvar så här länge; en fråga står kvar tills den besvarats.
38const DONE_NOTICE_MS = 10 * 60_000
39const MAX_NOTICES = 3
40const LABEL_COLUMNS = 8
41
42// Moddarnas API har ingen expires_at som statusraden, så cachens utgång
43// räknas från när huvudloopens senaste tur tog slut. TTL:en är en timme,
44// vad sessionen kör med; i overage sjunker den till fem minuter och då
45// kommer varningen för sent.
46const CACHE_TTL_MS = 60 * 60_000
47const CACHE_LEAD_MS = 3 * 60_000
48
49const rowsRef = { plugin: 'sessions', key: 'rows' } as const
50const noticesRef = { plugin: 'sessions', key: 'notices' } as const
51const cacheRef = { plugin: 'sessions', key: 'cache' } as const
52const lastTurnAtRef = { plugin: 'sessions', key: 'lastTurnAt' } as const
53const driftRef = { plugin: 'sessions', key: 'drift' } as const
54
55let selfId: string | null = null
56// Null tills första pollningen: den sår bara läget, annars får varje
57// session som redan är klar en notis när modden laddas.
58let seen: Map<number, string> | null = null
59let cacheTimers: Timer[] = []
60
61type SessionFile = {
62 pid?: number
63 sessionId?: string
64 cwd?: string
65 name?: string
66 status?: string
67 waitingFor?: string
68 statusUpdatedAt?: number
69 updatedAt?: number
70 entrypoint?: string
71}
72
73async function poll($: EngineInterface, notify: boolean) {
74 const home = (await $.env.get('HOME')) ?? ''
75 const dir = `${home}/.claude/sessions`
76 const entries = await $.fs.list(dir).catch(() => [])
77
78 const files: SessionFile[] = []
79 for (const entry of entries) {
80 if (!entry.name.endsWith('.json')) continue
81 const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => '')
82 try {
83 files.push(JSON.parse(text) as SessionFile)
84 } catch {
85 // En fil mitt i en skrivning; nästa varv läser den hel.
86 }
87 }
88
89 const pids = files.map(f => f.pid).filter((p): p is number => typeof p === 'number')
90 const alive = new Set<number>()
91 if (pids.length > 0) {
92 const ps = await $.process.run(['ps', '-o', 'pid=', '-p', pids.join(',')]).catch(() => null)
93 for (const line of ps?.stdout.split('\n') ?? []) {
94 const pid = Number(line.trim())
95 if (pid > 0) alive.add(pid)
96 }
97 }
98
99 const rows: SessionRow[] = files
100 .filter(f => typeof f.pid === 'number' && alive.has(f.pid))
101 .map(f => ({
102 pid: f.pid as number,
103 name: f.name ?? String(f.pid),
104 cwd: home && f.cwd?.startsWith(home) ? `~${f.cwd.slice(home.length)}` : (f.cwd ?? ''),
105 status: f.status ?? 'okänd',
106 ...(f.status === 'waiting' && f.waitingFor ? { waitingFor: f.waitingFor } : {}),
107 since: f.statusUpdatedAt ?? f.updatedAt ?? 0,
108 isSelf: f.sessionId === selfId,
109 isApp: f.entrypoint === 'claude-desktop',
110 }))
111 .sort((a, b) => b.since - a.since)
112
113 // Notiserna: nya byten läggs till, och en notis försvinner när sessionen
114 // byter läge igen, försvinner helt, eller (klar) blir för gammal.
115 const now = await $.clock.now()
116 const { value: noticesBefore = [] } = await $.state.get(noticesRef)
117 const byPid = new Map(rows.map(row => [row.pid, row]))
118 let notices: SessionNotice[] = noticesBefore.filter(notice => {
119 const row = byPid.get(notice.pid)
120 if (row === undefined) return false
121 if (notice.kind === 'waiting') return row.status === 'waiting'
122
123 return row.status === 'idle' && now - notice.at < DONE_NOTICE_MS
124 })
125 if (notify && seen !== null) {
126 for (const row of rows) {
127 const was = seen.get(row.pid)
128 if (row.isSelf || was === undefined || was === row.status) continue
129 const notice: SessionNotice | null =
130 row.status === 'waiting'
131 ? { pid: row.pid, name: row.name, kind: 'waiting', detail: row.waitingFor ?? 'väntar på svar', at: now }
132 : isWorking(was) && row.status === 'idle'
133 ? { pid: row.pid, name: row.name, kind: 'done', detail: 'klar', at: now }
134 : null
135 if (notice) notices = [notice, ...notices.filter(one => one.pid !== row.pid)]
136 // En fråga blockerar sessionen tills du svarar: den får ljud, en klar
137 // gör det inte (med många sessioner igång blir det annars ett plingande).
138 if (notice?.kind === 'waiting') {
139 void $.process.run(['afplay', '/System/Library/Sounds/Ping.aiff']).catch(() => {})
140 }
141 }
142 }
143 notices = notices.slice(0, MAX_NOTICES)
144 if (JSON.stringify(noticesBefore) !== JSON.stringify(notices)) await $.state.set(noticesRef, notices)
145
146 seen = new Map(rows.map(row => [row.pid, row.status]))
147
148 // Skriv bara när något ändrats, annars ritas bandet om var femte sekund.
149 const { value: before = [] } = await $.state.get(rowsRef)
150 if (JSON.stringify(before) !== JSON.stringify(rows)) await $.state.set(rowsRef, rows)
151}
152
153// busy är en tur, shell ett !-kommando; båda betyder att den inte väntar.
154function isWorking(status: string) {
155 return status !== 'idle' && status !== 'waiting'
156}
157
158function age(ms: number) {
159 const s = Math.max(0, Math.floor(ms / 1000))
160 if (s < 60) return 'nyss'
161 if (s < 3600) return `${Math.floor(s / 60)}m`
162 if (s < 86_400) return `${Math.floor(s / 3600)}h`
163
164 return `${Math.floor(s / 86_400)}d`
165}
166
167// Tre grupper: de som jobbar, de som väntar på dig (de som står på en fråga
168// först), och de som varit klara i över tolv timmar.
169function arrange(rows: SessionRow[], now: number) {
170 const isDormant = (row: SessionRow) =>
171 !row.isSelf && row.status === 'idle' && now - row.since > DORMANT_MS
172 const active = rows.filter(row => isWorking(row.status))
173 const waiting = rows
174 .filter(row => !isWorking(row.status) && !isDormant(row))
175 .sort((x, y) => Number(y.status === 'waiting') - Number(x.status === 'waiting'))
176 const dormant = rows.filter(isDormant)
177
178 return { active, waiting, dormant }
179}
180
181// Namnet som det står i bandet: desktop-appens sessioner märks, så man vet
182// att man ska leta i appen och inte bland terminalfönstren.
183function label(row: SessionRow) {
184 return row.isApp ? `${row.name} app` : row.name
185}
186
187// Listan som text, för /sessions: samma grupper och tecken, plus katalogen.
188function listText(rows: SessionRow[], now: number) {
189 const { active, waiting, dormant } = arrange(rows, now)
190 const line = (row: SessionRow) =>
191 `${dotFor(row).glyph} ${label(row)}${row.isSelf ? ' (den här)' : ''} · ${age(now - row.since)} · ${row.cwd}`
192 const parts = [
193 active.length > 0 ? `jobbar\n${active.map(line).join('\n')}` : '',
194 waiting.length > 0 ? `väntar\n${waiting.map(line).join('\n')}` : '',
195 dormant.length > 0 ? `vilande\n${dormant.map(line).join('\n')}` : '',
196 ].filter(Boolean)
197
198 return parts.length > 0 ? parts.join('\n\n') : 'Inga sessioner.'
199}
200
201// Claude Code skriver om settings.json när man ändrar något i /config, och
202// har minst en gång ersatt stow-länken med en vanlig fil. Då glider
203// live-konfigurationen och flottans baslinje isär utan att någon märker det.
204// Som SessionStart-hook sa kollen till först i nästa session; här körs den
205// vid start, efter varje /config-ändring och vid varje prompt, och notisen
206// står kvar tills länken är tillbaka.
207async function checkSettings($: EngineInterface) {
208 const home = (await $.env.get('HOME')) ?? ''
209 const tilde = (path: string) => (home && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
210 const live = `${home}/.claude/settings.json`
211 // Repot hittas genom mods-länken, där modden själv bor. Utan stow pekar den
212 // ingenstans annars och det finns inget att jämföra med.
213 const mods = await $.fs.stat(`${home}/.claude/mods`, { resolve: true }).catch(() => undefined)
214 const repoDir = mods?.realPath?.replace(/\/mods$/, '')
215 const repo = `${repoDir}/settings.json`
216 const hasRepo =
217 repoDir !== undefined &&
218 repoDir !== `${home}/.claude` &&
219 (await $.fs.stat(repo).then(stat => stat.kind === 'file', () => false))
220
221 let drift: SettingsDrift | null = null
222 if (hasRepo) {
223 const link = `ln -sfn ${tilde(repo)} ~/.claude/settings.json`
224 const stat = await $.fs.stat(live).catch(() => undefined)
225 if (stat === undefined) {
226 drift = { kind: 'missing', fix: link }
227 } else if (!stat.isLink) {
228 const [ours, theirs] = await Promise.all([$.fs.read(live).catch(() => null), $.fs.read(repo).catch(() => null)])
229 drift =
230 ours !== null && ours === theirs
231 ? { kind: 'unlinked', fix: link }
232 : { kind: 'diverged', fix: `diff ~/.claude/settings.json ${tilde(repo)}` }
233 }
234 }
235
236 const { value: before = null } = await $.state.get(driftRef)
237 if (JSON.stringify(before) !== JSON.stringify(drift)) await $.state.set(driftRef, drift)
238}
239
240function disarmCache() {
241 for (const timer of cacheTimers) timer.cancel()
242 cacheTimers = []
243}
244
245// Ställer timrarna för en tur som tog slut vid turnEndedAt. Under de sista
246// minuterna skrivs nedräkningen om varje halvminut, så raden visar rätt.
247async function armCache($: EngineInterface, turnEndedAt: number) {
248 disarmCache()
249 const expiresAt = turnEndedAt + CACHE_TTL_MS
250 const now = await $.clock.now()
251 const minutesLeft = async () => Math.max(1, Math.ceil((expiresAt - (await $.clock.now())) / 60_000))
252
253 if (expiresAt - CACHE_LEAD_MS > now) {
254 cacheTimers.push(
255 $.clock.after(expiresAt - CACHE_LEAD_MS - now, () => {
256 void $.state.set(cacheRef, { kind: 'warn', minutesLeft: CACHE_LEAD_MS / 60_000 })
257 // Systemljudet direkt, så ingen av Apples filer hamnar i repot. Finns
258 // bara på macOS; på Arch blir det tyst och raden får räcka.
259 void $.process.run(['afplay', '/System/Library/Sounds/Glass.aiff']).catch(() => {})
260 cacheTimers.push(
261 $.clock.every(30_000, () => {
262 void minutesLeft().then(left => $.state.set(cacheRef, { kind: 'warn', minutesLeft: left }))
263 }),
264 )
265 }),
266 )
267 }
268 if (expiresAt > now) {
269 cacheTimers.push(
270 $.clock.after(expiresAt - now, () => {
271 disarmCache()
272 void $.state.set(cacheRef, { kind: 'cold', minutesLeft: 0 })
273 }),
274 )
275 } else {
276 await $.state.set(cacheRef, { kind: 'cold', minutesLeft: 0 })
277 }
278}
279
280export const register: Register = (on, options) => {
281 const notify = options.notifyIdle !== false
282 // Sessionsöversikten är pausad som standard; cachevarningen gäller alltid.
283 const showSessions = options.showSessions === true
284
285 on('session.start', async ($, e, next) => {
286 const started = await next(e)
287 await checkSettings($).catch(() => {})
288 if (showSessions) {
289 selfId = await $.session.id()
290 await $.command.register({
291 name: 'sessions',
292 description: 'Lista alla Claude-sessioner på maskinen, med katalog',
293 })
294 await poll($, notify)
295 $.clock.every(POLL_MS, () => {
296 void poll($, notify)
297 })
298 }
299 // En omladdning tappar timrarna men inte $.state: ställ om dem.
300 const { value: lastTurnAt } = await $.state.get(lastTurnAtRef)
301 if (lastTurnAt != null) await armCache($, lastTurnAt)
302
303 return started
304 })
305
306 // Medan en tur kör hålls cachen varm av anropen själva.
307 on('prompt.submit', async ($, e, next) => {
308 disarmCache()
309 await $.state.set(cacheRef, null)
310 void checkSettings($).catch(() => {})
311
312 return next(e)
313 })
314
315 // /config är det som brukar ersätta länken: kolla direkt efter skrivningen.
316 on('config.set', async ($, e, next) => {
317 const done = await next(e)
318 await checkSettings($).catch(() => {})
319
320 return done
321 })
322
323 // Subagenternas turer har egna prefix, och ett avbrott före första svaret
324 // har inget usage och förnyade ingenting.
325 on('turn.complete', async ($, e, next) => {
326 const done = await next(e)
327 if (e.agentId === undefined && e.usage !== undefined) {
328 const now = await $.clock.now()
329 await $.state.set(lastTurnAtRef, now)
330 await $.state.set(cacheRef, null)
331 await armCache($, now)
332 }
333
334 return done
335 })
336
337 // Bandet kan fällas ihop med [-] eller ctrl+x ctrl+a, och det är Claude
338 // Codes eget: en mod kan inte fälla ut det igen. /sessions ger listan ändå.
339 on('command.run', { command: 'sessions' }, async $ => {
340 await poll($, notify)
341 const { value: rows = [] } = await $.state.get(rowsRef)
342
343 return {
344 text: `${listText(rows, await $.clock.now())}\n\nctrl+x ctrl+a fäller ut bandet ovanför prompten igen.`,
345 }
346 })
347
348 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
349 // Pausad översikt: det som ligger kvar i state från förut ritas inte.
350 const { value: storedRows = [] } = await $.state.get(rowsRef)
351 const { value: storedNotices = [] } = await $.state.get(noticesRef)
352 const rows = showSessions ? storedRows : []
353 const notices = showSessions ? storedNotices : []
354 const { value: cache = null } = await $.state.get(cacheRef)
355 const { value: drift = null } = await $.state.get(driftRef)
356 const hasNotices = notices.length > 0 || cache !== null || drift !== null
357 if (e.props.hasSurvey || (rows.length === 0 && !hasNotices)) return next(e)
358
359 const { Box, Text } = $.ui.resolve(e)
360 const now = await $.clock.now()
361 const { active, waiting, dormant } = arrange(rows, now)
362
363 const chip = (row: SessionRow) => sessionChip(Text, row, now)
364 const group = (title: string, members: SessionRow[], tail: SessionRow[] = []) => (
365 <Box key={title} flexDirection="row">
366 <Box width={LABEL_COLUMNS} flexShrink={0}>
367 <Text dimColor>{title}</Text>
368 </Box>
369 <Box flexDirection="row" flexWrap="wrap" columnGap={3} flexShrink={1}>
370 {members.map(chip)}
371 {tail.length > 0 && (
372 <Text key="dormant-label" dimColor>
373 vilande
374 </Text>
375 )}
376 {tail.map(row => sessionChip(Text, row, now, true))}
377 </Box>
378 </Box>
379 )
380
381 return (
382 <Box flexDirection="column">
383 {hasNotices && (
384 <Box key="notices" flexDirection="row" flexWrap="wrap" columnGap={3}>
385 {drift !== null && driftChip(Text, drift)}
386 {cache !== null && cacheChip(Text, cache)}
387 {notices.map(notice => noticeChip(Text, notice, now))}
388 </Box>
389 )}
390 {active.length > 0 && group('jobbar', active)}
391 {(waiting.length > 0 || dormant.length > 0) && group('väntar', waiting, dormant)}
392 </Box>
393 )
394 })
395}
396
397// Gult för den som behöver dig: varm färg är varning, som i statusraden.
398// Grönt för jobbar. Grått för klar.
399function dotFor(row: SessionRow) {
400 if (row.status === 'waiting') return { glyph: '●', color: 'yellow' }
401 if (isWorking(row.status)) return { glyph: '●', color: 'green' }
402
403 return { glyph: '○', color: undefined }
404}
405
406// Färgerna skrivs ansi:<namn>: ett bart namn som 'green' går genom Claude
407// Codes tema (dark-daltonized här) och blir en annan nyans än statusradens.
408// ansi: är terminalens egen palett, samma som statusraden använder.
409//
410// Notiserna är etiketter: terminalens egen färg omvänd, så de syns i
411// ögonvrån men följer temat. Rött för en fråga (den blockerar), gult för
412// cachen, grönt för klar. En kall cache är bara nedtonad text.
413function cacheChip(Text: ElementConstructor<TextProps>, cache: CacheNotice) {
414 return cache.kind === 'warn' ? (
415 <Text key="cache" color="yellow" inverse bold>
416 {` ◷ promptcachen går kall om ${cache.minutesLeft} min `}
417 </Text>
418 ) : (
419 <Text key="cache" dimColor>
420 ◷ promptcachen är kall
421 </Text>
422 )
423}
424
425// Gul som cachen: en varning, inget som blockerar. Kommandot står nedtonat
426// efter, så det går att kopiera rakt av.
427function driftChip(Text: ElementConstructor<TextProps>, drift: SettingsDrift) {
428 const title = {
429 missing: 'settings.json saknas',
430 unlinked: 'settings.json är inte längre en länk till dotfiles',
431 diverged: 'settings.json har glidit isär från dotfiles',
432 }[drift.kind]
433
434 return (
435 <Text key="drift">
436 <Text color="yellow" inverse bold>
437 {` ⚠ ${title} `}
438 </Text>
439 <Text dimColor> {drift.fix}</Text>
440 </Text>
441 )
442}
443
444function noticeChip(Text: ElementConstructor<TextProps>, notice: SessionNotice, now: number) {
445 const isQuestion = notice.kind === 'waiting'
446
447 return (
448 <Text key={`notice-${notice.pid}`}>
449 <Text color={isQuestion ? 'red' : 'green'} inverse bold>
450 {isQuestion ? ` ▲ ${notice.name} behöver ditt svar ` : ` ✓ ${notice.name} är klar `}
451 </Text>
452 <Text dimColor>
453 {isQuestion ? ` ${notice.detail} · ` : ' '}
454 {age(now - notice.at)}
455 </Text>
456 </Text>
457 )
458}
459
460// En session i bandet: namnet i lägets färg, "app" och åldern nedtonade
461// direkt efter. Grönt jobbar, gult väntar på din nästa prompt, fet röd står
462// på en fråga och blockerar, grått vilar. Den egna i fet cyan, som i
463// statusraden.
464//
465// Namnen ritas nedtonade: Claude Code tonar ner hela statusraden, så samma
466// ANSI-färger ser ut som där först med dim. Notiserna ovanför är inte
467// nedtonade, de ska dra blicken.
468function sessionChip(Text: ElementConstructor<TextProps>, row: SessionRow, now: number, isDormant = false) {
469 const color = row.isSelf
470 ? 'cyan'
471 : row.status === 'waiting'
472 ? 'red'
473 : isWorking(row.status)
474 ? 'green'
475 : isDormant
476 ? undefined
477 : 'yellow'
478
479 return (
480 <Text key={String(row.pid)}>
481 <Text color={color} dimColor bold={row.isSelf || row.status === 'waiting'}>
482 {row.name}
483 </Text>
484 {row.isApp && <Text dimColor> app</Text>}
485 <Text dimColor> {age(now - row.since)}</Text>
486 </Text>
487 )
488}
489types/index.d.ts 49 lines1// En levande Claude Code-session på maskinen, som ~/.claude/sessions/<pid>.json
2// beskriver den. `status` är busy (jobbar), waiting (står på en fråga eller
3// en behörighetsdialog, `waitingFor` säger vilken) eller idle (klar, väntar
4// på nästa prompt). `since` är när statusen senast bytte, i epoch-ms.
5export type SessionRow = {
6 pid: number
7 name: string
8 cwd: string
9 status: string
10 waitingFor?: string
11 since: number
12 isSelf: boolean
13 isApp: boolean
14}
15
16// Något som hänt en annan session och som du bör se: den står på en fråga
17// (waiting) eller har jobbat klart (done). Ligger kvar i bandet tills
18// sessionen byter läge igen, en klar-notis högst DONE_NOTICE_MS.
19export type SessionNotice = {
20 pid: number
21 name: string
22 kind: 'waiting' | 'done'
23 detail: string
24 at: number
25}
26
27// Promptcachen i notisraden: snart kall (med minuter kvar) eller kall. Null
28// när det inte finns något att säga, som medan en tur kör.
29export type CacheNotice = { kind: 'warn' | 'cold'; minutesLeft: number }
30
31// ~/.claude/settings.json har slutat vara en länk in i dotfiles: den saknas,
32// är en vanlig fil med samma innehåll, eller har glidit isär. `fix` är
33// kommandot som återställer eller visar skillnaden. Null när länken håller.
34export type SettingsDrift = { kind: 'missing' | 'unlinked' | 'diverged'; fix: string }
35
36declare module 'claude-code' {
37 interface PluginState {
38 sessions: {
39 rows: SessionRow[]
40 notices: SessionNotice[]
41 cache: CacheNotice | null
42 // När huvudloopens senaste tur tog slut, så en omladdning kan ställa om
43 // cachens timrar.
44 lastTurnAt: number | null
45 drift: SettingsDrift | null
46 }
47 }
48}
49