SLOPSHOPPER

turn-footer

Adds the end time and tokens used to the line that closes each turn, plus a one-line recap of the reply

spinnermodel
v0.1.0no licenseupdated 2026-10-19jackyyeh5111/dotfiles/claude/mods/turn-footer
A shopper browsing a rack in a slop shop

Draws

Turn line
✻ Worked for 42s · done Thursday 8:53 AM · 1.5k tokens
README

dotfiles

Personal dotfiles + provisioning scripts for a brand new machine:

  • setup.sh — Debian/Ubuntu (apt)
  • setup.mac.sh — macOS (Homebrew)

Quickstart

git clone <this-repo-url> ~/dotfiles
cd ~/dotfiles

./setup.sh        # Debian/Ubuntu
./setup.mac.sh    # macOS

Both scripts are organized into one section per tool, check whether each tool is already installed before doing work again (safe to re-run), and back up (never delete) any existing config file before replacing it with a symlink. If any step fails, the script prints exactly which step, command, and line failed and stops — fix the issue and re-run it; completed steps are skipped automatically.

What setup.sh installs

  • Base packages: python3/pip, vim, htop, tmux, git, openssh-server, build tools
  • ripgrep, neofetch, fzf, bat, fd, xclip, astyle, eza
  • zsh + oh-my-zsh + zsh-autosuggestions + fzf-tab
  • starship prompt, zoxide
  • Rust (rustup)
  • virtualenvwrapper
  • uv (official install script)
  • just
  • delta (release binary into ~/.local/bin)
  • ccache (apt) and mold (release build into ~/.local); .zshrc.linux points CMake at ccache and sets LDFLAGS=-fuse-ld=mold
  • Docker (official convenience script)
  • Neovim (latest official release build, not the apt package)
  • yazi (latest official release build)
  • herdr
  • Claude Code
  • Symlinks all dotfiles into place (.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*, just/justfile as ~/.justfile)
  • git config --global core.editor nvim, plus delta as git's pager
  • Best-effort bootstrap of nvim plugins (lazy.nvim) and yazi plugins (ya pkg install)
  • Claude Code config via claude/skills/claude-setup/install.sh: links settings, CLAUDE.md, keybindings, hooks, skills and mods into ~/.claude, installs every enabled plugin, then checks it all. Re-run it (or ask Claude to use the claude-setup skill) after adding a skill or mod

What setup.mac.sh installs

Same set, via Homebrew, minus what macOS already provides:

  • Homebrew itself (and the Xcode Command Line Tools check that precedes it)
  • Base packages: python3, git, tmux, vim, htop, wget
  • ripgrep, fastfetch, fzf, bat, fd, astyle, eza
  • oh-my-zsh + zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources), plus fzf-tab as an oh-my-zsh custom plugin
  • starship prompt, zoxide
  • Rust (rustup, so ~/.cargo/env exists for .zshrc.mac)
  • virtualenvwrapper (brew formula, so it lands in $(brew --prefix)/bin)
  • uv (official install script)
  • just
  • delta (git-delta)
  • ccache, used by CMake via .zshrc.mac (no mold; it has no macOS build)
  • Docker Desktop (cask)
  • Neovim, yazi, herdr (brew), Claude Code (install script)
  • Ghostty (cask — unlike Linux, it's automated here)
  • Symlinks all dotfiles into place, with .zshrc.mac as ~/.zshrc
  • git config --global core.editor nvim, plus delta as git's pager
  • Best-effort bootstrap of nvim and yazi plugins

Differences from the Linux script, all deliberate:

  • No zsh, build tools, curl/unzip/gpg or xclip — macOS ships zsh and the clipboard is pbcopy; compilers come from the Command Line Tools.
  • No openssh-server — macOS has sshd built in, just toggled off.
  • No bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.
  • neofetch is gone from homebrew-core (archived upstream); fastfetch takes its place.
  • The script warns at the end if .zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).

Manual steps

A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:

  • Default shell — if chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.
  • Docker group (Linux) — setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).
  • Docker Desktop (macOS) — setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists.
  • Remote Login (macOS) — macOS ships sshd but leaves it off; enable it in System Settings → General → Sharing → Remote Login.
  • Go — install from https://go.dev/dl/ (download the linux tarball and extract to /usr/local/go, matching the PATH entry already in .zshrc.linux).
  • Node.js — install nvm, then: ``bash nvm install --lts ` Note: .zshrc.linux also has a hardcoded PATH entry pointing at a specific past node version directory — harmless if it doesn't exist, just update it to match whatever nvm installs if you want node/npm always on PATH` without loading nvm.
  • Codex CLI — install per OpenAI's current instructions for the Codex CLI; left out here since the install method changes independently of this repo.
  • Ghostty (terminal app) — Linux only: install via your distro's package manager, snap, or from https://ghostty.org/download; setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.
  • SSH key for GitHub — generate one and add it to your GitHub account if you'll be pushing over SSH: ``bash ssh-keygen -t ed25519 -C "your_email@example.com" ``
  • First nvim launch — setup.sh best-effort bootstraps plugins headlessly; if that step warned, just open nvim once and let lazy.nvim finish, then Mason will install LSP servers as configured in nvim/lua/plugins.lua.
Source 2 files
hooks/register.tsx 79 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TurnEnd } from '../types'
5
6// Recent turn ends, newest last. The closing line only knows its duration,
7// so it finds its own turn here by that duration.
8const ends = atom({ plugin: 'turn-footer', key: 'ends' } as const, [])
9const KEEP = 200
10
11const RECAP_MODEL = 'claude-haiku-5-5'
12const RECAP_SYSTEM =
13  'Summarize the assistant reply you are given in one short plain sentence (under 25 words): what was done or answered. No preamble, no markdown.'
14
15const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
16
17// 108000 -> "1m 48s", 3000 -> "3s", 3723000 -> "1h 2m 3s"
18const formatDuration = (ms: number) => {
19  const total = Math.max(1, Math.round(ms / 1000))
20  const h = Math.floor(total / 3600)
21  const m = Math.floor((total % 3600) / 60)
22  const s = total % 60
23  if (h) return `${h}h ${m}m ${s}s`
24  if (m) return `${m}m ${s}s`
25  return `${s}s`
26}
27
28// 1700000000000 -> "Tuesday 5:45 PM", in this machine's time zone
29const formatEnd = (ms: number) => {
30  const d = new Date(ms)
31  const hour = d.getHours() % 12 || 12
32  const minute = String(d.getMinutes()).padStart(2, '0')
33  return `${DAYS[d.getDay()]} ${hour}:${minute} ${d.getHours() < 12 ? 'AM' : 'PM'}`
34}
35
36// 950 -> "950", 12345 -> "12.3k"
37const formatTokens = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`)
38
39export const register: Register = on => {
40  on('turn.complete', async ($, e, next) => {
41    const result = await next(e)
42    if (e.agentId) return result
43
44    const end: TurnEnd = {
45      durationMs: e.durationMs,
46      endedAt: await $.clock.now(),
47      outputTokens: e.usage ? e.usage.output_tokens : null,
48    }
49    await update($, ends, list => [...(list ?? []), end].slice(-KEEP))
50
51    if (e.reason !== 'answer' || !result.text.trim()) return result
52
53    const recap = await $.model.complete({
54      model: RECAP_MODEL,
55      system: RECAP_SYSTEM,
56      prompt: result.text,
57      maxTokens: 120,
58      timeoutMs: 15000,
59    })
60    if (!recap.isAnswered || !recap.text.trim()) return result
61
62    // A different text is drawn beneath the answer; the answer itself stays.
63    return { ...result, text: `※ recap: ${recap.text.trim()}` }
64  })
65
66  // "✻ Crunched for 1m 48s · done Sunday 5:45 PM · 2.1k tokens"
67  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
68    const list = await read($, ends)
69    const end = list.findLast(t => Math.abs(t.durationMs - e.props.durationMs) < 1000)
70    if (!end) return next(e)
71
72    const { Text } = $.ui.resolve(e)
73    const parts = [`${e.props.word} for ${formatDuration(e.props.durationMs)}`, `done ${formatEnd(end.endedAt)}`]
74    if (end.outputTokens !== null) parts.push(`${formatTokens(end.outputTokens)} tokens`)
75
76    return <Text dimColor>✻ {parts.join(' · ')}</Text>
77  })
78}
79
types/index.d.ts 9 lines
1// One finished main-loop turn, as turn.complete reported it.
2export type TurnEnd = { durationMs: number; endedAt: number; outputTokens: number | null }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'turn-footer': { ends: TurnEnd[] }
7  }
8}
9