SLOPSHOPPER

context-meter

Band above the prompt showing where the context window goes: tool calls, tool results, thinking, prompts, replies, system

newband
A shopper browsing a rack in a slop shop
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 2 files
hooks/register.tsx 113 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MeterRow, Snapshot } from '../types'
5
6const snapshot = atom({ plugin: 'context-meter', key: 'snapshot' } as const, null)
7
8type Block = { type: string; [field: string]: unknown }
9
10const size = (value: unknown): number =>
11  typeof value === 'string' ? value.length : JSON.stringify(value ?? '').length
12
13// Character counts per kind of block; only their proportions matter, since they
14// are scaled onto the engine's own Messages figure.
15function splitMessages(messages: { role: string; content: Block[] }[]) {
16  const chars = { toolCalls: 0, toolResults: 0, thinking: 0, prompts: 0, replies: 0 }
17  for (const message of messages) {
18    for (const block of message.content) {
19      if (block.type === 'tool_use') chars.toolCalls += size(block.input) + size(block.name)
20      else if (block.type === 'tool_result') chars.toolResults += size(block.content)
21      else if (block.type === 'thinking') chars.thinking += size(block.thinking)
22      else if (block.type === 'redacted_thinking') chars.thinking += size(block.data)
23      else if (message.role === 'user') chars.prompts += size(block.text ?? block)
24      else chars.replies += size(block.text ?? block)
25    }
26  }
27  return chars
28}
29
30async function measure($: EngineInterface): Promise<Snapshot | null> {
31  const { context } = await $.session.usage({ breakdown: 'summary' })
32  const breakdown = context.breakdown
33  if (!breakdown) return null
34
35  const rows: MeterRow[] = []
36  for (const category of breakdown.categories) {
37    if (category.kind !== 'used' || category.tokens <= 0) continue
38    // The engine has no kind for the transcript row, so its /context label is
39    // the only handle for splitting it further.
40    if (category.name !== 'Messages') {
41      rows.push({ label: category.name, tokens: category.tokens, color: category.color })
42      continue
43    }
44    const chars = splitMessages(await $.session.messages({ as: 'api' }))
45    const totalChars = Object.values(chars).reduce((a, b) => a + b, 0) || 1
46    const share = (n: number) => Math.round((category.tokens * n) / totalChars)
47    rows.push(
48      { label: 'Tool results', tokens: share(chars.toolResults), color: 'warning' },
49      { label: 'Tool calls', tokens: share(chars.toolCalls), color: 'autoAccept' },
50      { label: 'Thinking', tokens: share(chars.thinking), color: 'planMode' },
51      { label: 'Your prompts', tokens: share(chars.prompts), color: 'suggestion' },
52      { label: 'Claude replies', tokens: share(chars.replies), color: 'claude' },
53    )
54  }
55  rows.sort((a, b) => b.tokens - a.tokens)
56
57  // The breakdown is per-category estimates; the last response's input tokens
58  // are exact, so headline that figure and scale the categories to sum to it.
59  const used = context.tokens ?? breakdown.totalTokens
60  const scale = breakdown.totalTokens > 0 ? used / breakdown.totalTokens : 1
61
62  return {
63    used,
64    window: context.window,
65    percent: context.percent ?? Math.round((used / context.window) * 100),
66    rows: rows
67      .map(row => ({ ...row, tokens: Math.round(row.tokens * scale) }))
68      .filter(row => row.tokens > 0),
69  }
70}
71
72const k = (n: number) =>
73  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
74
75export const register: Register = on => {
76  on('session.measure', async ($, e, next) => {
77    const fresh = await measure($)
78    if (fresh) await update($, snapshot, () => fresh)
79    return next(e)
80  })
81
82  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
83    const snap = await read($, snapshot)
84    if (e.props.hasSurvey || !snap) return next(e)
85
86    const { Box, Text } = $.ui.resolve(e)
87    const header = `Context ${k(snap.used)}/${k(snap.window)} (${snap.percent}%) `
88    const barWidth = Math.max(10, e.props.bodyColumns - header.length)
89    const cells = snap.rows.map(row => Math.round((row.tokens / snap.window) * barWidth))
90    const free = Math.max(0, barWidth - cells.reduce((a, b) => a + b, 0))
91
92    return (
93      <Box flexDirection="column" marginBottom={1}>
94        <Text wrap="truncate-end">
95          <Text bold>{header}</Text>
96          {snap.rows.map((row, i) => (
97            <Text color={row.color}>{'█'.repeat(cells[i] ?? 0)}</Text>
98          ))}
99          <Text dimColor>{'░'.repeat(free)}</Text>
100        </Text>
101        <Text wrap="wrap">
102          {snap.rows.map(row => (
103            <Text>
104              <Text color={row.color}>■ </Text>
105              <Text dimColor>{`${row.label} ${k(row.tokens)}   `}</Text>
106            </Text>
107          ))}
108        </Text>
109      </Box>
110    )
111  })
112}
113
types/index.d.ts 9 lines
1export type MeterRow = { label: string; tokens: number; color: string }
2export type Snapshot = { used: number; window: number; percent: number; rows: MeterRow[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'context-meter': { snapshot: Snapshot | null }
7  }
8}
9