SLOPSHOPPER

drivis

Drivis-paletten i transkriptet: prompter och svar i egna rutor, svarens markdown i drivisens kalla färger

newrows
★ 1v0.1.0no licenseupdated 2026-10-11eehnsio/dotfiles/claude/.claude/mods/drivis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drivis
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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
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 86 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { printable, renderMarkdown } from './markdown'
4
5// Drivis i transkriptet, efter yugal1107/ui-themes: varje prompt och varje
6// svar i en egen ruta, så att det syns var ens egen text slutar och Claudes
7// börjar utan att man letar efter ›-tecknet.
8//
9// ui-themes har flera teman, en väljare och en egen statuslinje. Här finns
10// bara drivis, och färgerna står i koden i stället för i JSON-filer under
11// ~/.claude: riset har en palett, och den bor i ghostty/themes/drivis.
12// Statuslinjen har vi redan, och den tar sina färger från terminalen.
13//
14// Bara i terminalen. Claude-appen och de andra ytorna ritar transkriptet med
15// sin egen stil och formatering, och där kom rutorna dessutom på plats först
16// en stund efter att appen redan ritat sitt.
17//
18// Prompten får markeringens bakgrund, så den läses som "det här valde du".
19// Svaret får index 0, en nyans mörkare än terminalen, så rutan syns utan att
20// ta över.
21const PROMPT_BG = '#2b3342' // selection-background
22const REPLY_BG = '#101216'  // palette 0
23// Ett kommando i prompten får accenten, som Claude Code själv färgar dem.
24const COMMAND = '#7fc8ff'   // palette 6
25
26// /namn räknas bara som kommando när namnet finns i sessionens kommandolista,
27// så att sökvägar (/tmp/x) och "och/eller" inte färgas. Det kan stå var som
28// helst i prompten, inte bara först: "kör på /commit" kör /commit också.
29// Listan hämtas om högst en gång i minuten; skills som dyker upp under
30// sessionen kommer med vid nästa hämtning.
31const SLASH = /(^|\s)(\/[\w:.-]+)/g
32let commands = new Set<string>()
33let fetchedAt = 0
34
35async function knownCommands($: EngineInterface) {
36  if (Date.now() - fetchedAt > 60_000) {
37    fetchedAt = Date.now()
38    try { commands = new Set((await $.command.list()).map(c => c.name)) } catch {}
39  }
40  return commands
41}
42
43function withCommands(text: string, known: Set<string>, Text: any) {
44  const out: any[] = []
45  let last = 0
46  for (const m of text.matchAll(SLASH)) {
47    const token = m[2] ?? ''
48    if (!known.has(token.slice(1))) continue
49    const at = (m.index ?? 0) + (m[1] ?? '').length
50    if (at > last) out.push(text.slice(last, at))
51    out.push(<Text key={`c${at}`} bold color={COMMAND}>{token}</Text>)
52    last = at + token.length
53  }
54  if (last < text.length) out.push(text.slice(last))
55  return out
56}
57
58export const register: Register = on => {
59  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
60    // Bara det du själv skrev vid prompten får rutan. Allt annat som ritas som
61    // ett användarmeddelande (bakgrundsjobb, andra agenter och sessioner,
62    // kanaler, schemalagda körningar, plugins) ritar motorn med sin egen
63    // märkning, så att det aldrig ser ut som något du har skrivit.
64    if (e.surface !== 'terminal') return next(e)
65    if (e.props.origin.kind !== 'composer' || e.props.task || e.props.from) return next(e)
66    const { Box, Text } = $.ui.resolve(e)
67    return (
68      <Box backgroundColor={PROMPT_BG} paddingX={2} paddingY={1} marginTop={1}>
69        <Text>{withCommands(printable(e.props.text), await knownCommands($), Text)}</Text>
70      </Box>
71    )
72  })
73
74  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
75    // Sammanfattningar ritar motorn: bara dess rad får ` · summary`-märket.
76    if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
77    const t = $.ui.resolve(e)
78    const { Box } = t
79    return (
80      <Box backgroundColor={REPLY_BG} paddingX={2} paddingY={1} marginTop={1}>
81        {renderMarkdown(e.props.text, t)}
82      </Box>
83    )
84  })
85}
86
hooks/markdown.tsx 162 lines
1// En liten markdown-renderare för svaren, så att de kan få drivisens färger.
2// Lånad från yugal1107/ui-themes. Kodblock och tabeller ritar motorn själv.
3//
4// Färgerna är värden ur ghostty/themes/drivis och följer samma roller som där:
5// cyanen är accenten (rubriker, länkar), blått är det man pekar ut (kod, som
6// oftast är sökvägar och namn), grått är metadata. Inget varmt: gult och rött
7// betyder varning och fel i hela riset, och ett svar är ingetdera.
8export const MD: Record<'code' | 'bold' | 'heading' | 'link' | 'bullet' | 'quote' | 'rule', string> = {
9  code: '#8fb4e4',    // 12, ljusblå
10  bold: '#d2d7df',    // 15, full styrka
11  heading: '#7fc8ff', // 6, accenten
12  link: '#7fc8ff',
13  bullet: '#6f9ad4',  // 4, blå
14  quote: '#aeb4c0',   // 7, mellannivån
15  rule: '#4a505c',    // 8, outline
16}
17
18// Styrtecken (ESC, BEL, C1) tas bort innan något ritas: texten kommer från
19// modellen och från vad den har läst, och en escape-sekvens i den kunde
20// annars flytta markören, byta titel på fönstret eller gömma en länk.
21// Radbrytning och tab får vara kvar. Likaså bort: riktningstecknen och de
22// osynliga tecknen (U+200B–200F, U+202A–202E, U+2066–2069, U+FEFF), som kan
23// vända eller dölja en del av en adress så att den läses som en annan.
24export const printable = (s: string) =>
25  s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g, '')
26
27type El = any
28type Table = { Box: any; Text: any; Link: any; Code: any; Markdown: any }
29
30// `code`, **bold**, *italic* / _italic_, [text](url)
31const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\[[^\]\n]+\]\([^)\s]+\))|(\*[^*\s][^*\n]*\*)|(\b_[^_\n]+_\b)/g
32
33export function inline(s: string, t: Table, keyBase: string): El[] {
34  const { Text, Link } = t
35  const out: El[] = []
36  let last = 0
37  let k = 0
38  for (const m of s.matchAll(INLINE)) {
39    const at = m.index ?? 0
40    if (at > last) out.push(s.slice(last, at))
41    const tok = m[0]
42    const key = `${keyBase}-${k++}`
43    if (m[1]) out.push(<Text key={key} color={MD.code}>{tok.slice(1, -1)}</Text>)
44    else if (m[2]) out.push(<Text key={key} bold color={MD.bold}>{tok.slice(2, -2)}</Text>)
45    else if (m[3]) {
46      const [, label, href] = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/) ?? []
47      // Klickbar bara för http(s), och adressen skrivs ut när länktexten
48      // inte är adressen själv: "[github.com](https://annat.se)" ska inte
49      // kunna se ut som något annat än det den öppnar.
50      const shown = label === href ? null : <Text dimColor>{` (${href})`}</Text>
51      out.push(/^https?:\/\//.test(href ?? '')
52        ? <Text key={key}><Link href={href}><Text color={MD.link} underline>{label}</Text></Link>{shown}</Text>
53        : <Text key={key}><Text color={MD.link} underline>{label}</Text>{shown}</Text>)
54    } else out.push(<Text key={key} italic>{tok.slice(1, -1)}</Text>)
55    last = at + tok.length
56  }
57  if (last < s.length) out.push(s.slice(last))
58  return out
59}
60
61export function renderMarkdown(src: string, t: Table): El {
62  const { Box, Text, Code, Markdown } = t
63  const lines = printable(src).replace(/\s+$/, '').split('\n')
64  const blocks: El[] = []
65  let i = 0
66  const push = (el: El) => blocks.push(
67    <Box key={`b${blocks.length}`} marginTop={blocks.length ? 1 : 0} flexDirection="column">{el}</Box>,
68  )
69
70  while (i < lines.length) {
71    const line = lines[i] ?? ''
72
73    if (!line.trim()) { i++; continue }
74
75    // ```lang fenced code
76    const fence = line.match(/^\s*```(\S*)/)
77    if (fence) {
78      const body: string[] = []
79      i++
80      while (i < lines.length && !/^\s*```\s*$/.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
81      i++
82      push(<Code source={body.join('\n')} language={fence[1] || undefined} />)
83      continue
84    }
85
86    // tables: hand to the engine
87    if (/^\s*\|/.test(line)) {
88      const rows: string[] = []
89      while (i < lines.length && /^\s*\|/.test(lines[i] ?? '')) rows.push(lines[i++] ?? '')
90      push(<Markdown text={rows.join('\n')} />)
91      continue
92    }
93
94    // # headings
95    const head = line.match(/^(#{1,6})\s+(.*)$/)
96    if (head) {
97      push(<Text bold color={MD.heading}>{inline(head[2] ?? '', t, `h${i}`)}</Text>)
98      i++
99      continue
100    }
101
102    // --- rule
103    if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) {
104      push(<Text color={MD.rule}>{'─'.repeat(40)}</Text>)
105      i++
106      continue
107    }
108
109    // > quote
110    if (/^\s*>/.test(line)) {
111      const q: string[] = []
112      while (i < lines.length && /^\s*>/.test(lines[i] ?? '')) q.push((lines[i++] ?? '').replace(/^\s*>\s?/, ''))
113      push(
114        <Box flexDirection="row">
115          <Text color={MD.quote}>│ </Text>
116          <Text color={MD.quote}>{inline(q.join(' '), t, `q${i}`)}</Text>
117        </Box>,
118      )
119      continue
120    }
121
122    // - / * / 1. lists (with nesting by indent)
123    if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
124      const items: El[] = []
125      while (i < lines.length) {
126        const l = lines[i] ?? ''
127        const m = l.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
128        if (m) {
129          const depth = Math.floor((m[1] ?? '').length / 2)
130          const marker = /\d/.test(m[2] ?? '') ? (m[2] ?? '') : '•'
131          let text = m[3] ?? ''
132          i++
133          // continuation lines indented under the item
134          while (i < lines.length && /^\s{2,}\S/.test(lines[i] ?? '') && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i] ?? '')) {
135            text += ' ' + (lines[i++] ?? '').trim()
136          }
137          items.push(
138            <Box key={`li${i}`} flexDirection="row" paddingLeft={depth * 2}>
139              <Box width={marker.length + 1} flexShrink={0}><Text color={MD.bullet}>{marker}</Text></Box>
140              <Box flexGrow={1} flexShrink={1}><Text>{inline(text, t, `li${i}`)}</Text></Box>
141            </Box>,
142          )
143        } else break
144      }
145      push(<Box flexDirection="column">{items}</Box>)
146      continue
147    }
148
149    // paragraph: join until a blank line or another block starts
150    const para: string[] = []
151    while (i < lines.length) {
152      const l = lines[i] ?? ''
153      if (!l.trim() || /^\s*(```|\||#{1,6}\s|>|([-*+]|\d+[.)])\s)/.test(l)) break
154      para.push(l.trim())
155      i++
156    }
157    push(<Text>{inline(para.join(' '), t, `p${i}`)}</Text>)
158  }
159
160  return <Box flexDirection="column">{blocks}</Box>
161}
162