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


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 86 lines1import 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}
86hooks/markdown.tsx 162 lines1// 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