Draws tool-call rows as a status dot, the tool name and a short summary instead of the raw input

A calm WSL/Linux terminal workspace: Zsh, tmux Main, Neovim, and a Tokyo Night theme. The normal path is intentionally boring; visual extras are optional.
Supported platforms: Debian/Ubuntu (including WSL), Arch, Fedora, and macOS. A standalone native-Windows setup lives in windows/.
git clone https://github.com/neoslusion/dotfiles.git ~/sandboxes/dotfiles
cd ~/sandboxes/dotfiles
./install.sh
Fresh machine, pinned checkout (the bootstrap script is fetched from the default branch, but the checkout revision is fixed by the SHA argument):
bash <(curl -fsSL https://raw.githubusercontent.com/neoslusion/dotfiles/main/scripts/bootstrap) <commit-sha>
Pass DOTFILES_ALLOW_MOVING_BOOTSTRAP_REF=1 only when intentionally tracking a branch or tag. Override the checkout location with DOTFILES_DEST.
./install.sh --config # reapply core configs only
./install.sh --verbose # stream raw install output
./install.sh --doctor # non-mutating health check
system_install (in installation/utils.sh) detects the package manager (apt-get → dnf → pacman, covering derivatives) and translates Debian-canonical package names per distro via map_package. Tools missing from a distro's repos come from SHA256-pinned GitHub release binaries (musl builds), so behavior matches across distros. Validate installer changes against clean containers with:
bash tests/distro_smoke.sh # ubuntu:22.04 + fedora + arch
bash tests/distro_smoke.sh archlinux:latest # one image
Installer logs live in:
~/.local/state/dotfiles/install-*.log
Network-installed tool versions are pinned in:
installation/versions.env
The release asset each tool downloads per architecture is listed once in installation/release_assets, which both the installer and bump-version read.
Bump that file intentionally when you want newer upstream releases. For one-off testing, export an override such as DOTFILES_NEOVIM_VERSION=v0.12.3 before running ./install.sh.
Maintenance helpers:
scripts/bump-version lazygit # bump a versions.env pin to latest (downloads + hashes)
scripts/bump-version nvim 0.12.4 # or pin an exact version (no v prefix); --dry-run to preview
scripts/bump-version --check # report drift for every tool (read-only)
scripts/sync-nvim-locks # snapshot live nvim plugin/LSP locks into the repo
git config core.hooksPath .githooks # enable pre-commit checks (policy + gitleaks)
Bump tools one at a time so breakage stays bisectable. A weekly drift workflow runs --check and reports stale pins as a GitHub issue (no code changes).
Check the live setup (installed versions vs versions.env pins, config deployment presence and parity, agent homes, secrets scaffold) without changing anything:
./install.sh --doctor
For copy-managed config modules, doctor also checks that repository-owned files still match the deployed files. User-created destination files and runtime-managed seed files are intentionally ignored. Run the full local test suite (Windows-side checks included when pwsh is on PATH):
bash tests/run_all.sh # + --smoke to add the docker distro smoke
Git hooks are optional but recommended (fast policy/path checks on commit):
git config core.hooksPath .githooks
To ensure the best experience on MacOS (Terminal.app or iTerm2), you need to adjust a few terminal-level settings so that keys like Alt (Option) and Home/End work correctly.
Most of the keybindings in this setup (pane switching, workspace management) use the Alt key. On Mac, you must tell your terminal to treat the Option key as Meta:
Esc+.If your Home/End keys scroll the window instead of moving the cursor:
After install, close the current terminal and open a new one.
Expected default: a plain Zsh prompt. On Windows, herdr owns panes/tabs (hjkl chords - see windows/README.md); tmux is installed but does not auto-attach.
To restore the old auto-attached Main session:
DOTFILES_TMUX=1 zsh # or export it in ~/.secrets/env.sh to make it stick
The daily command surface uses unified Alt chords across both Herdr (primary terminal workspace manager) and Tmux (terminal muxer), with seamless vim-aware navigation into Neovim splits and instant Lazygit popups:
| Key | Action | Herdr | Tmux | Neovim |
|---|---|---|---|---|
Alt + h / j / k / l | Focus pane Left / Down / Up / Right | Focus pane | Focus / Neovim split | Move to split |
Alt + Arrow | Move between Neovim splits | — (reaches the app) | — (reaches the app) | Move to split |
Alt + n | Split side-by-side (vertical split) | Yes | Yes | — |
Alt + - | Split stacked (horizontal split) | Yes | Yes | — |
Alt + z | Toggle pane zoom / fullscreen | Yes | Yes | — |
Alt + q | Close focused pane | Yes | Yes | :q<CR> |
Alt + t | New tab / window | Yes | Yes | :tabnew<CR> |
Alt + ] / [ | Next / previous tab or window | Yes | Yes | gt / gT |
Alt + 1..9 | Direct tab / window jump | Yes | Yes | — |
Alt + w | Workspace / session picker | Yes | Yes (choose-tree) | — |
Alt + b | Toggle sidebar / status bar | Toggle sidebar | Toggle status bar | — |
Alt + g | Lazygit floating popup | Yes (85% modal) | Yes (85% popup) | Yes (Snacks popup) |
(See KEYBINDINGS.md for the complete three-layer doctrine, cross-tool comparison matrix, and full Lazygit cheat sheet.)
If the terminal feels weird:
tmux source-file ~/.tmux.conf
./install.sh --config
audit-nvim-keymaps
./install.sh --config also deploys sanitized seeds for Claude Code, Codex, Antigravity, and Opencode:
config/agents/ → the shared engineering guidance (ENGINEERING.md), deployed to every agent home below.config/claude/ → ~/.claude/ (settings.json plus the shared canon above). Other installers (such as rtk) and /plugin write into the same files, so settings.json is merged, not replaced: repo keys win, every other key is kept, and the pre-merge file is kept as .bak. CLAUDE.md only gets an @ENGINEERING.md import line appended if it lacks one. A key deleted from the repo is not removed from the live file. Its permissions block denies force pushes and asks before any git push or rm -rf; those two lists replace the live ones on each refresh, so add extra rules to the repo copy. Same deploy on Windows via .\install.ps1 -ConfigOnly.config/codex/ → ~/.codex/ (AGENTS.md only gets an @~/.codex/ENGINEERING.md import line appended if it lacks one, since rtk adds its own line there; config.toml is seeded only if missing — Codex appends project trust entries at runtime and a refresh must not wipe them).config/opencode/ → ~/.config/opencode/ (AGENTS.md + the shared canon; opencode.json is seed-if-missing only — Opencode writes user prefs into it — and lists the shared docs via its instructions array, since Opencode has no @file imports).The only agent plugins installed are this repo's own Claude Code mods, config/claude/mods/ → ~/.claude/mods/ (context-meter: context usage band above the prompt; pretty-tools: tool calls drawn as blocks with their diff or output; cache-timer: minutes left on the 1-hour prompt cache, under the prompt). They are registered as the local my-mods marketplace through the claude CLI on every refresh, skipped with a warning when claude is not on PATH. Add any other plugins, marketplaces, or hook tools per machine after the base install. The installer leaves content it does not manage in the agent homes untouched.
On Linux, the full installer normally executes the vendor-provided Claude and Codex shell installers. To deploy configs and pinned tools without those two remote installers, set DOTFILES_SKIP_AGENT_INSTALLERS=1.
On Windows, the PowerShell sidecar is deployed to the local ~\PowerShell\dotfiles.profile.ps1; $PROFILE only contains a small dot-source wrapper so Known Folder Move/OneDrive is not on the hot path.
No credentials live in this repo. Auth comes from machine-local files the installer scaffolds but never fills:
~/.secrets/env.sh # sourced by zsh — machine-local exports
codex login # Codex OAuth; recreates ~/.codex/auth.json per machine
Never commit auth.json, ~/.secrets/, session/history files, or Codex's rules/ (accumulated approval state with machine paths) — .gitignore guards all of these defensively.
On native Windows the installer manages Alacritty (windows/alacritty/alacritty.toml → %APPDATA%lacritty\, because Alacritty reads no XDG path on Windows). It uses Catppuccin Macchiato as outer chrome so the tmux strip inside a WSL shell stays visually distinct. See windows/README.md.
Open Neovim and use <leader>? or :VSCodeKeys for the local VS Code-style key cheat sheet.
After :Lazy update / :MasonUpdate, snapshot both lockfiles back into the repo with scripts/sync-nvim-locks (run --restore on a fresh machine before :Lazy restore).
On native Windows, Neo-tree refreshes on editor saves instead of keeping OS file watchers on busy build trees. Press R in the explorer to refresh changes made by external tools. Clangd keeps background indexing and clang-tidy, with two workers on Windows, lower indexing priority, and error-only logging. Language servers use Neovim's native configuration API; color previews load only for supported filetypes instead of delaying the first insert everywhere. The status line reuses Gitsigns' cached diff counts, and the symbol outline loads when requested rather than tracking every buffer from startup.
Files detected by Snacks as large (over 1.5 MB or average lines over 1,000 bytes) skip completion scans, regex syntax and indent guides. Undo remains persistent, with 1,000 changes for ordinary buffers and 100 for large buffers.
To audit Neovim keymap overlaps after editing:
audit-nvim-keymaps
install.sh: supported user entry point.installation/: installer building blocks used by install.sh.config/: source configs copied into $HOME and ~/.config.scripts/: maintenance helpers installed into ~/.local/bin.AGENTS.md: contributor and agent guidance.Apache-2.0. You may use, modify, and redistribute this repo; keep the NOTICE file, which credits the author, with any copy or derivative.
hooks/register.tsx 184 lines1import type { Register, RenderElement } from 'claude-code'
2
3type Input = Record<string, unknown>
4type Hunk = { oldStart: number; newStart: number; lines: string[] }
5
6// Rows the body of one block may take before it says how much is left out.
7const BODY_ROWS = 16
8const OUTPUT_ROWS = 8
9
10// Tools whose block already shows what the result would; the engine's own
11// result row under them is hidden so the content is not drawn twice.
12const SELF_CONTAINED = new Set(['Edit', 'Write', 'Bash', 'Read'])
13
14const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
15const obj = (value: unknown) =>
16 value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
17
18// Last two path segments: enough to tell files apart without the drive prefix.
19const shortPath = (path: unknown) => {
20 const parts = (str(path) ?? '').split(/[\\/]/).filter(Boolean)
21 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : parts.join('/')
22}
23
24const firstLine = (text: string) => {
25 const [head, ...rest] = text.trim().split('\n')
26 return rest.length ? `${head} …` : (head ?? '')
27}
28
29export function summarize(tool: string, input: Input): { name: string; detail: string } {
30 if (tool.startsWith('mcp__')) {
31 const [, server, name] = tool.split('__')
32 return { name: `${server} › ${name ?? tool}`, detail: '' }
33 }
34 switch (tool) {
35 case 'Bash':
36 return { name: 'Shell', detail: str(input.description) ?? firstLine(str(input.command) ?? '') }
37 case 'Read': {
38 const offset = typeof input.offset === 'number' ? input.offset : undefined
39 const limit = typeof input.limit === 'number' ? input.limit : undefined
40 const range = offset !== undefined && limit !== undefined ? ` (lines ${offset}–${offset + limit - 1})` : ''
41 return { name: 'Read', detail: shortPath(input.file_path) + range }
42 }
43 case 'Edit':
44 case 'Write':
45 case 'NotebookEdit':
46 return { name: tool, detail: shortPath(input.file_path ?? input.notebook_path) }
47 case 'Grep': {
48 const where = str(input.glob) ?? (input.path ? shortPath(input.path) : '')
49 return { name: 'Search', detail: `"${str(input.pattern) ?? ''}"${where ? ` in ${where}` : ''}` }
50 }
51 case 'Glob':
52 return { name: 'Find files', detail: str(input.pattern) ?? '' }
53 case 'Agent':
54 return { name: 'Agent', detail: str(input.description) ?? '' }
55 case 'Skill':
56 return { name: 'Skill', detail: str(input.skill) ?? '' }
57 case 'WebFetch':
58 return { name: 'Fetch', detail: (str(input.url) ?? '').replace(/^https?:\/\//, '') }
59 case 'WebSearch':
60 return { name: 'Web search', detail: str(input.query) ?? '' }
61 default: {
62 const first = Object.values(input).find(v => typeof v === 'string') as string | undefined
63 return { name: tool, detail: first ? firstLine(first) : '' }
64 }
65 }
66}
67
68const KIND_COLOR: Record<string, string> = {
69 Read: 'suggestion',
70 Edit: 'warning',
71 Write: 'success',
72 NotebookEdit: 'warning',
73 Bash: 'bashBorder',
74 Grep: 'permission',
75 Glob: 'permission',
76 Agent: 'planMode',
77}
78
79// The engine's result records are untyped here, so each field is probed and the
80// block falls back to the call's own input when the result lacks it.
81function hunksOf(tool: string, input: Input, output: unknown): Hunk[] {
82 const patch = obj(output)?.structuredPatch
83 if (Array.isArray(patch) && patch.length > 0) {
84 return patch.flatMap(raw => {
85 const h = obj(raw)
86 const lines = Array.isArray(h?.lines) ? h.lines.filter((l): l is string => typeof l === 'string') : []
87 return lines.length ? [{ oldStart: Number(h?.oldStart) || 1, newStart: Number(h?.newStart) || 1, lines }] : []
88 })
89 }
90 if (tool !== 'Edit') return []
91 const before = str(input.old_string)
92 const after = str(input.new_string)
93 if (before === undefined || after === undefined) return []
94 const lines = [...before.split('\n').map(l => `-${l}`), ...after.split('\n').map(l => `+${l}`)]
95 return [{ oldStart: 1, newStart: 1, lines }]
96}
97
98// Keeps whole hunks within the row budget, cutting the last one short when it
99// overflows; each kept hunk's header is recounted so the diff still parses.
100export function diffSource(hunks: Hunk[], budget = BODY_ROWS): { source: string; hidden: number } {
101 const out: string[] = []
102 let used = 0
103 let hidden = 0
104 for (const hunk of hunks) {
105 const room = budget - used
106 const kept = room > 0 ? hunk.lines.slice(0, room) : []
107 hidden += hunk.lines.length - kept.length
108 if (!kept.length) continue
109 const oldCount = kept.filter(l => !l.startsWith('+')).length
110 const newCount = kept.filter(l => !l.startsWith('-')).length
111 out.push(`@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@`, ...kept)
112 used += kept.length
113 }
114 return { source: out.join('\n'), hidden }
115}
116
117const tail = (text: string, rows: number) => {
118 const lines = text.replace(/\s+$/, '').split('\n')
119 return { lines: lines.slice(-rows), hidden: Math.max(0, lines.length - rows) }
120}
121
122export const register: Register = on => {
123 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
124 if (e.surface !== 'terminal') return next(e)
125 const { Box, Text, Code } = $.ui.resolve(e)
126 const { tool, isRunning, isErrored, isInterrupted, output } = e.props
127 const input = (obj(e.props.input) ?? {}) as Input
128 const { name, detail } = summarize(tool, input)
129 const result = obj(output)
130
131 const status = isInterrupted ? 'inactive' : isErrored ? 'error' : isRunning ? 'warning' : 'success'
132 const border = isErrored ? 'error' : (KIND_COLOR[tool] ?? 'inactive')
133 const width = Math.max(30, (e.viewport?.columns ?? 100) - 2)
134
135 let body: RenderElement | null = null
136 let note = ''
137 if (!isRunning && !isErrored && !isInterrupted) {
138 if (tool === 'Edit' || tool === 'Write') {
139 const { source, hidden } = diffSource(hunksOf(tool, input, output))
140 if (source) {
141 body = <Code source={source} format="diff" wrap="truncate-end" />
142 if (hidden) note = `… ${hidden} more lines`
143 } else if (tool === 'Write') {
144 const lines = (str(input.content) ?? '').split('\n')
145 const shown = lines.slice(0, BODY_ROWS)
146 body = <Code source={shown.join('\n')} path={str(input.file_path)} startLine={1} wrap="truncate-end" />
147 if (lines.length > shown.length) note = `… ${lines.length - shown.length} more lines (new file)`
148 }
149 } else if (tool === 'Bash') {
150 const out = tail(`${str(result?.stdout) ?? ''}${str(result?.stderr) ? `\n${str(result?.stderr)}` : ''}`, OUTPUT_ROWS)
151 body = (
152 <Box flexDirection="column">
153 <Text dimColor wrap="truncate-end">{`$ ${firstLine(str(input.command) ?? '')}`}</Text>
154 {out.lines.join('').trim() !== '' && <Text wrap="truncate-end">{out.lines.join('\n')}</Text>}
155 </Box>
156 )
157 if (out.hidden) note = `… ${out.hidden} earlier lines`
158 } else if (tool === 'Read') {
159 const lines = Number(obj(result?.file)?.numLines)
160 if (lines) note = `${lines} lines`
161 }
162 }
163
164 return (
165 <Box flexDirection="column" borderStyle="round" borderColor={border} paddingX={1} width={width}>
166 <Box flexDirection="row">
167 <Text color={status}>{isRunning ? '◌ ' : '● '}</Text>
168 <Text bold color={border}>{name}</Text>
169 {detail !== '' && <Text dimColor wrap="truncate-end">{` ${detail}`}</Text>}
170 {isInterrupted && <Text color="inactive"> · interrupted</Text>}
171 </Box>
172 {body}
173 {note !== '' && <Text dimColor>{note}</Text>}
174 </Box>
175 )
176 })
177
178 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
179 if (e.surface !== 'terminal' || e.props.isErrored || !SELF_CONTAINED.has(e.props.tool)) return next(e)
180 const { Box } = $.ui.resolve(e)
181 return <Box />
182 })
183}
184