SLOPSHOPPER

sessions

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…

newbandcommandpromptprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-10eehnsio/dotfiles/claude/.claude/mods/sessions
A shopper browsing a rack in a slop shop
README

Dotfiles

Drivis

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/.

PackagePlatform
binHelper scripts → ~/.local/binboth
claudeClaude Code settings, skills, statuslineboth
fastfetchTwo views: environment, and ffhw for hardwareboth
ghosttyTerminal emulator, incl. cursor shaderboth
lsdls replacement, follows the terminal paletteboth
mpvVideo player: floating window, sized by the clipboth
niriScrolling tiling compositorLinux
noctaliaDesktop shell for niri: bar, notifications, OSD, lock screen, paletteLinux
nvimNeovim (LazyVim) + keybind cheatsheetboth
rbwBitwarden and Vaultwarden on Mod+P: copy a password without the clipboard historyLinux
rofiLauncher on Mod+Space and the card Mod+P draws in, styled after the login screenLinux
spotifyForces the client onto native WaylandLinux
tmuxSession persistence over SSH, not local splitsboth
wave-xlrElgato Wave XLR: daemon unit + mic silence watchdogLinux
xremapsuper → ctrl per app, below the compositorLinux
zshShell, prompt, aliasesboth

New machine

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 by baseline.yml in the homelab repo.

Installation

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.

Two machines

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.

Machine-specific config

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.

FileSeeded fromLoaded by
niri/.config/niri/local.kdllocal.kdl.exampleinclude "local.kdl"
ghostty/.config/ghostty/config.localconfig.local.exampleconfig-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.

Theme

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.

How it works

  • Stow creates symlinks from this repo into $HOME
  • Edit files here, not in ~/.config
  • Re-run ./install after pulling; it unstows before stowing, so it is safe to repeat
Source 2 files
hooks/register.tsx 489 lines
1import 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}
489
types/index.d.ts 49 lines
1// 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