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

Personal dotfiles + provisioning scripts for a brand new machine:
setup.sh — Debian/Ubuntu (apt)setup.mac.sh — macOS (Homebrew)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.
setup.sh installs~/.local/bin)~/.local); .zshrc.linux points CMake at ccache and sets LDFLAGS=-fuse-ld=mold.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 pagerlazy.nvim) and yazi plugins (ya pkg install)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 modsetup.mac.sh installsSame set, via Homebrew, minus what macOS already provides:
zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources), plus fzf-tab as an oh-my-zsh custom plugin~/.cargo/env exists for .zshrc.mac)$(brew --prefix)/bin)git-delta).zshrc.mac (no mold; it has no macOS build).zshrc.mac as ~/.zshrcgit config --global core.editor nvim, plus delta as git's pagerDifferences from the Linux script, all deliberate:
pbcopy; compilers come from the Command Line Tools.bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.fastfetch takes its place..zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:
chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists./usr/local/go, matching the PATH entry already in .zshrc.linux).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.setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.bash ssh-keygen -t ed25519 -C "your_email@example.com" ``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.hooks/register.tsx 79 lines1import { 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}
79types/index.d.ts 9 lines1// 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