SLOPSHOPPER

pretty-tools

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

newrows
v0.1.0Apache-2.0updated 2026-10-20neoslusion/dotfiles/config/claude/mods/pretty-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pretty-tools
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ● Edit …/src/auth.ts │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
╭─────────────────────────────────────────────────────────────────────────────────────────────────── │ ● Edit …/src/auth.ts ╰───────────────────────────────────────────────────────────────────────────────────────────────────
README

Tokyo Night Dotfiles

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

Install

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

Linux distros

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

Health & Tests

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

MacOS Configuration

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.

1. Enable Option as Meta (Alt Key)

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:

  • iTerm2: Settings -> Profiles -> Keys -> General -> Set Left Option Key to Esc+.
  • Terminal.app: Settings -> Profiles -> Keyboard -> Check Use Option as Meta key.

2. Home/End Keys

If your Home/End keys scroll the window instead of moving the cursor:

  • iTerm2: Settings -> Profiles -> Keys -> Key Bindings -> Click Presets... -> Select Natural Text Editing.

First Session

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

Everyday

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:

KeyActionHerdrTmuxNeovim
Alt + h / j / k / lFocus pane Left / Down / Up / RightFocus paneFocus / Neovim splitMove to split
Alt + ArrowMove between Neovim splits— (reaches the app)— (reaches the app)Move to split
Alt + nSplit side-by-side (vertical split)YesYes—
Alt + -Split stacked (horizontal split)YesYes—
Alt + zToggle pane zoom / fullscreenYesYes—
Alt + qClose focused paneYesYes:q<CR>
Alt + tNew tab / windowYesYes:tabnew<CR>
Alt + ] / [Next / previous tab or windowYesYesgt / gT
Alt + 1..9Direct tab / window jumpYesYes—
Alt + wWorkspace / session pickerYesYes (choose-tree)—
Alt + bToggle sidebar / status barToggle sidebarToggle status bar—
Alt + gLazygit floating popupYes (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.)

Recovery

If the terminal feels weird:

tmux source-file ~/.tmux.conf
./install.sh --config
audit-nvim-keymaps

Agent Configs (Claude Code / Codex)

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

Native Windows

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.

Neovim

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

Repository Map

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

License

Apache-2.0. You may use, modify, and redistribute this repo; keep the NOTICE file, which credits the author, with any copy or derivative.

Source 1 files
hooks/register.tsx 184 lines
1import 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