Collects git, PR/CI and tok/s for the statusLine command (cli/statusline.ts) and draws running dev servers above the prompt

Opinionated, curated dotfiles for the discerning developer
A comprehensive cross-platform dotfiles repository that makes strong choices about your development environment. Supporting macOS, Linux (Ubuntu, Fedora, Arch), and WSL with both personal and work configurations, it's designed for developers who want a thoughtfully curated setup without the decision fatigue.
[!TIP] The system automatically detects your platform and environment, making installation straightforward across different setups.
curl -fsSL https://jordangarcia.me/dotfiles/boot.sh | bash
This will automatically clone/update the repo, fetch latest changes, and run interactive setup.
git clone https://github.com/whoisjordangarcia/dotfiles.git
cd dotfiles
./bin/dot -i # Interactive setup
./bin/dot --system mac --work # Direct installation
./bin/dot --system linux_ubuntu --personal
For lightweight servers (no desktop, bash instead of zsh), use the standalone bootstrap — it installs git curl vim neovim tmux htop + Claude Code and symlinks the bash config. See server/README.md.
bash <(curl -fsSL https://raw.githubusercontent.com/whoisjordangarcia/dotfiles/main/server-init.sh)
configs/zshrc/)Modular Z shell configuration with organized .zshrc-modules/:
| Module | Purpose |
|---|---|
.zshrc.history | 50k command history with smart duplicate filtering, ignores mundane commands |
.zshrc.aliases | reload, vim→nvim, ls→eza, cat→bat, top→btop, git shortcuts |
.zshrc.functions | encode64/decode64, compress/decompress, web2app launcher creator |
.zshrc.plugins | Completions + plugins: zsh-syntax-highlighting, zsh-autosuggestions, nx-completion |
.zshrc.envvars | EDITOR, pyenv, colored man pages via bat |
.zshrc.vim-mode | Vi keybindings in shell |
.zshrc.work | Work-specific environment config |
Key Aliases:
gcm - checkout main, gpo - pull origin, gpf - push --force-with-leasewt-auto, wt-list, wt-remove - git worktree managementdotfiles - quick access to this repoconfigs/tmux/)Terminal multiplexer with extensive customization:
Ctrl+a (changed from default Ctrl+b)vim-tmux-navigator (Ctrl+h/j/k/l seamless pane switching)tmux-resurrect + tmux-continuum for persistenceCustom Scripts (~/.tmux/scripts/):
| Script | Function |
|---|---|
pane_title.sh | Gradient-colored pane borders by command (Claude=purple, nvim=green, nx=orange) |
session_picker.sh | Process-aware session selection with zoxide integration |
cpu_usage.sh | CPU monitoring for statusline |
memory_usage.sh | RAM usage display |
Plugins via TPM:
vim-tmux-navigator, tmux-yank, tmux-resurrect, tmux-continuumtmux-sessionx, tmux-floax, tmux-prefix-highlight, tmux-cpu, tmux-batteryconfigs/ghostty/)Fast, GPU-accelerated terminal emulator:
configs/starship/)Minimal, fast shell prompt:
configs/nvim/)LazyVim distribution with 27+ extras enabled:
AI & Coding:
claudecode - Claude Code integrationyanky - Enhanced clipboard managerLanguages:
Editor Enhancements:
aerial - Code outline/symbolsharpoon2 - Quick file navigationilluminate - Highlight word referencesmini-diff - Inline git diffsoutline - Symbol explorertreesitter-context - Shows code context at topAdditional:
configs/lazygit/)Terminal Git UI with custom commands:
c key: Create draft PR via gh pr create --web --draftn key: New branch with type menu (feature/fix/hotfix/chore/docs/refactor)jordan/{type}-{name}configs/rift/) - macOSTiling window manager with integrated SketchyBar:
SketchyBar Integration:
configs/hypr/) - Arch LinuxModern Wayland compositor:
| Config | Purpose |
|---|---|
hyprland.conf | Main entry, sources modular configs |
monitors.conf | Display setup for retina 2x (5K/6K) |
input.conf | Keyboard (US), touchpad settings |
looknfeel.conf | Window rounding (12px), gaps, dwindle layout |
HyDE Integration: Uses HyDE distribution for theming and extended configs.
configs/gtk-3.0/, gtk-4.0/, kvantum/, qt6ct/) - Arch LinuxLinux apps use two different UI toolkits, each with their own theming stack. These configs lock in a readable dark theme and prevent HyDE's wallbash auto-theming from generating unreadable color combinations.
GTK apps (themed by gtk-3.0/settings.ini + gtk-4.0/settings.ini + gsettings):
| App | What it is |
|---|---|
| Brave | Web browser |
| Firefox | Web browser |
| Ghostty | Terminal emulator |
| Spotify | Music |
| Lutris | Game launcher |
| Nautilus | GNOME file manager |
| Pavucontrol | Volume control |
| Waybar | Status bar |
| File picker dialogs | xdg-desktop-portal-gtk |
Qt apps (themed by qt6ct/qt6ct.conf + kvantum/kvantum.kvconfig):
| App | What it is |
|---|---|
| Dolphin | KDE file manager |
| Ark | Archive manager |
| Claude Desktop | AppImage |
| Steam | Uses Qt underneath |
Current theme choices:
Gruvbox-Retro (dark) with Gruvbox-Plus-Dark iconsKvGnomeDark enginedarker.conf color schemecatppuccin-mocha-green-cursors[!NOTE] HyDE's wallbash system dynamically generates a
Wallbash-Gtktheme from wallpaper colors and sets it via gsettings. Thetheming/linuxsetup script overrides this back toGruvbox-Retrofor consistent readability.
configs/waybar/) - Wayland Status BarCustomizable panel for Hyprland:
Modules:
Styling:
configs/dolphin/) - KDE File ManagerFeature-rich file manager for Arch Linux:
View Settings:
Service Menus (right-click actions):
.desktop actionsconfigs/claude/)Claude Code IDE settings and configuration.
| Directory | Purpose |
|---|---|
configs/ssh/ | SSH config with global defaults + Include ~/.ssh/hosts.local for per-machine host aliases |
configs/git/ | .gitconfig.template, .gitignore_global, work.gitconfig |
configs/bat/ | Syntax highlighting for cat replacement |
configs/brave/ | Browser customization |
configs/codex/ | Codex AI integration |
configs/opencode/ | OpenCode settings |
configs/fastfetch/ | System info display |
configs/pyright/ | Python type checking |
configs/i3/ | i3 window manager (Fedora) |
configs/sway/ | Sway compositor |
configs/glazeWM/ | GlazeWM (Windows) |
configs/man/ | Custom man pages for dotfiles |
mac)./bin/dot --system mac
Installs: Homebrew + macOS defaults, 1Password, Git/SSH/GitHub CLI, Zsh, Neovim, Node, Tmux, Starship, Rift (tiling WM) + Ghostty, cmux, LazyGit, Claude/Codex/OpenCode + Agents, Fastfetch, Brave, Sunshine
Work variant (--work): Same installer, same component list — script/mac_components.sh is the single source of truth. WORK_ENV=1 only changes environment-specific config (work git email, etc.), not which components install.
linux_arch)./bin/dot --system linux_arch
Installs: Pacman packages, Git/SSH/GitHub CLI, Node, LazyGit, Zsh, Vim, Tmux, Bat, Ghostty, Fonts, Starship, Fastfetch, Brave, Podman, Claude/Codex + Agents, Dolphin, HyDE theming (Hyprland/Rofi/Btop), VPN split-tunnel, UFW
Pacman packages: zsh, starship, tmux, ripgrep, eza, zoxide, wl-clipboard, fzf, jq, bat, dysk, htop, btop, ttf-jetbrains-mono-nerd, mangohud, darktable, podman, yubikey-manager, github-cli
linux_ubuntu)./bin/dot --system linux_ubuntu
Installs: Core dev tools via apt, Git/SSH, Zsh, Vim, Tmux, Node, LazyGit, Starship, Fastfetch, Brave
linux_fedora)./bin/dot --system linux_fedora
Installs: Git/Zsh/Vim/Tmux, i3wm, Polybar, LazyGit, Fonts, Starship, Fastfetch, Brave
server/)Standalone, bash-based — not part of the bin/dot flow. No desktop, no zsh.
bash <(curl -fsSL https://raw.githubusercontent.com/whoisjordangarcia/dotfiles/main/server-init.sh)
Installs: git, curl, vim, neovim, tmux, htop (apt) + Claude Code (native installer). Symlinks bashrc, bash_profile, aliases, tmux.conf, plus the shared functions/ and gitconfig. Details + how to extend: server/README.md.
dotfiles/
├── bin/
│ └── dot # Shell management CLI
├── boot.sh # Remote bootstrap script
├── server-init.sh # Headless server one-liner bootstrap
├── server/ # Bash config for servers/Proxmox/LXC (see server/README.md)
├── script/
│ ├── common/
│ │ ├── log.sh # Logging utilities (section, step, info, success, fail)
│ │ └── symlink.sh # Symlink with conflict resolution ([O]verride/[B]ackup/[S]kip)
│ ├── *_installation.sh # Platform installers (mac, linux_arch, etc.)
│ └── {component}/ # Component setup scripts
│ └── {platform}/setup.sh
├── configs/ # Configuration files (symlinked to home)
│ ├── rift/ # macOS tiling WM + SketchyBar
│ ├── nvim/ # Neovim + LazyVim
│ ├── tmux/ # Tmux + custom scripts
│ ├── zshrc/ # Zsh + modular configs
│ ├── hypr/ # Hyprland (Arch)
│ ├── waybar/ # Wayland status bar
│ ├── dolphin/ # KDE file manager
│ ├── ghostty/ # Terminal emulator
│ ├── starship/ # Shell prompt
│ ├── lazygit/ # Git TUI
│ └── ...
└── .dotconfig # User preferences (auto-generated)
User preferences stored in .dotconfig:
DOT_NAME="Jordan Garcia"
DOT_EMAIL="user@example.com"
DOT_ENVIRONMENT="work|personal"
DOT_SYSTEM="mac|linux_arch|..."
DOT_YUBIKEY="ABC123..." # Optional GPG key for git signing
./bin/dot -h # Show help
./bin/dot -i # Interactive setup
./bin/dot -l # List available profiles
./bin/dot -m # Select modules interactively
./bin/dot -s # Show system detection
./bin/dot -c # View current config
./bin/dot -e # Open dotfiles in $EDITOR
./bin/dot --reset-config # Clear config and start fresh
./bin/dot --reconfigure # Re-run interactive setup
tmux source ~/.tmux.conf # Reload config
# Inside tmux: <prefix> + I to install plugins
Plugins are sourced directly from ~/.zsh/plugins/. Install via git clone:
git clone https://github.com/zsh-users/zsh-syntax-highlighting ~/.zsh/plugins/zsh-syntax-highlighting
git clone https://github.com/zsh-users/zsh-autosuggestions ~/.zsh/plugins/zsh-autosuggestions
gpg --list-secret-keys --keyid-format=long # Find key ID
# Provide during ./bin/dot -i or set manually
script/{component}/{platform}/setup.shscript/{platform}_installation.shscript/common/log.shconfigs/{component}/bin/dot detect_system()script/{platform}_installation.sh| Issue | Solution |
|---|---|
| Permission denied | Don't run with sudo - scripts handle elevation |
| Missing dependencies | Run platform installation script first |
| Config conflicts | ./bin/dot --reset-config to start fresh |
| Tmux plugins missing | Inside tmux: <prefix> + I |
Tested across macOS, Arch Linux, Ubuntu, Fedora, and WSL. Fork and adapt for your needs.
hooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { collectBaseRef, collectCi, collectGit, collectNodeApps, collectPr, osc8Supported } from './statusline/collect'
5import { ciGlyph, KNOWN_APPS, nodeAppUrl } from './statusline/render'
6import type { Apps } from '../types'
7import type { Io, Shared } from './statusline/types'
8
9// The statusLine command (cli/statusline.ts) renders each run from its own payload plus this file, so
10// the slow parts (git, gh, lsof) and tok/s never hold it up: /tmp/statusline-mod/<session id>.state.
11const DIR = '/tmp/statusline-mod'
12
13const apps = atom({ plugin: 'statusline-mod', key: 'apps' } as const, [] as Apps)
14
15// Each source on its own lifetime, so the 1s statusLine refresh never waits on the slow ones: git moves
16// as you work; the base branch, PR and CI rarely do (and cost a network call or a dozen git runs).
17const TTL_MS = { git: 3000, base: 600_000, pr: 120_000, ci: 60_000, node: 30_000, osc8: 60_000 }
18const POLL_MS = 1000
19
20const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
21// The statusLine re-reads once a second; 250ms keeps each read on a fresh frame without busy writes.
22const TICK_MS = 250
23// Usage only arrives when the step ends, so the live rate guesses tokens from streamed text.
24const CHARS_PER_TOKEN = 4
25
26const mod = {
27 id: '',
28 // speed starts empty, not absent: the statusLine then holds its tok/s row from the first draw.
29 shared: { cwd: '', baseRef: '', ci: '', nodeApps: [], speed: {} } as Shared,
30 fetched: { git: -Infinity, base: -Infinity, pr: -Infinity, ci: -Infinity, node: -Infinity, osc8: -Infinity },
31 /** The branch the PR, CI and base were fetched for. */
32 branch: '',
33 isRefreshing: false,
34 written: '',
35 frame: 0,
36 turnTimer: undefined as { cancel: () => void } | undefined,
37 /** The main agent's current reply stream, for the live tok/s estimate. */
38 stream: undefined as { first?: number; chars: number; ticks: number } | undefined,
39}
40
41// A set-up checkout (node_modules present) lists every app it holds, running or not, so a down one shows;
42// anywhere else only what is running. Running apps the list doesn't know follow, as found.
43async function appRows(io: Io, cwd: string, running: string[]): Promise<Apps> {
44 const ports = new Map(running.map(e => [e.slice(0, e.lastIndexOf(':')), e.slice(e.lastIndexOf(':') + 1)]))
45 const rows: Apps = []
46 if (await io.exists(`${cwd}/node_modules`)) {
47 for (const [name, dir] of Object.entries(KNOWN_APPS)) {
48 if (await io.exists(`${cwd}/${dir}`)) rows.push({ name, port: ports.get(name) })
49 }
50 // Mailpit runs in the shared Docker stack, whose proxy (not node) holds the port, so the port is probed.
51 if (await io.exists(`${cwd}/docker-compose.shared.yml`)) {
52 const up = (await io.run(['lsof', '-tiTCP:8025', '-sTCP:LISTEN'], cwd)).exitCode === 0
53 rows.push({ name: 'mailpit', port: up ? '8025' : undefined })
54 }
55 }
56 for (const [name, port] of ports) if (!rows.some(r => r.name === name)) rows.push({ name, port })
57 return rows
58}
59
60function ioFor($: EngineInterface): Io {
61 return {
62 run: async (argv, cwd) => {
63 try {
64 const r = await $.process.run(argv, { cwd, timeoutMs: 15_000 })
65 return { exitCode: r.exitCode, stdout: r.stdout }
66 } catch {
67 return { exitCode: 127, stdout: '' }
68 }
69 },
70 exists: path => $.fs.exists(path),
71 }
72}
73
74async function write($: EngineInterface) {
75 const text = JSON.stringify(mod.shared)
76 if (!mod.id || text === mod.written) return
77 mod.written = text
78 await $.fs.write(`${DIR}/${mod.id}.state`, text)
79}
80
81async function refresh($: EngineInterface) {
82 if (mod.isRefreshing) return
83 mod.isRefreshing = true
84 try {
85 const io = ioFor($)
86 const now = await $.clock.now()
87 const s = mod.shared
88 const cwd = await $.session.cwd()
89 if (cwd !== s.cwd) {
90 s.cwd = cwd
91 mod.fetched = { ...mod.fetched, git: -Infinity, base: -Infinity, pr: -Infinity, ci: -Infinity, node: -Infinity }
92 s.git = undefined
93 s.nodeApps = []
94 await update($, apps, () => [])
95 }
96
97 if (now - mod.fetched.git >= TTL_MS.git) {
98 s.git = await collectGit(io, cwd)
99 mod.fetched.git = now
100 }
101 const branch = s.git?.branch ?? ''
102 if (branch !== mod.branch) {
103 mod.branch = branch
104 mod.fetched.base = mod.fetched.pr = mod.fetched.ci = -Infinity
105 s.baseRef = ''
106 s.pr = undefined
107 s.ci = ''
108 }
109 await write($)
110
111 if (branch && now - mod.fetched.base >= TTL_MS.base) {
112 s.baseRef = await collectBaseRef(io, cwd, branch)
113 mod.fetched.base = now
114 }
115 if (branch && now - mod.fetched.pr >= TTL_MS.pr) {
116 const pr = await collectPr(io, cwd, branch)
117 s.pr = pr.url ? pr : undefined
118 mod.fetched.pr = now
119 }
120 // CI only matters while the PR is open.
121 if (s.pr?.state === 'OPEN' && now - mod.fetched.ci >= TTL_MS.ci) {
122 s.ci = ciGlyph(await collectCi(io, cwd, branch))
123 mod.fetched.ci = now
124 }
125 // The lsof scan is skipped unless this looks like a Node project or an earlier scan found apps.
126 if (now - mod.fetched.node >= TTL_MS.node && (s.nodeApps.length || (await io.exists(`${cwd}/package.json`)))) {
127 s.nodeApps = await collectNodeApps(io, cwd)
128 mod.fetched.node = now
129 const rows = await appRows(io, cwd, s.nodeApps)
130 if (JSON.stringify(rows) !== JSON.stringify(await read($, apps))) await update($, apps, () => rows)
131 }
132 if (now - mod.fetched.osc8 >= TTL_MS.osc8) {
133 const vars = {
134 TMUX: await $.env.get('TMUX'),
135 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
136 TERM: await $.env.get('TERM'),
137 VTE_VERSION: await $.env.get('VTE_VERSION'),
138 WT_SESSION: await $.env.get('WT_SESSION'),
139 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
140 }
141 const client = vars.TMUX ? (await io.run(['tmux', 'display', '-p', '#{client_termname}'])).stdout : ''
142 s.osc8 = osc8Supported(vars, client)
143 mod.fetched.osc8 = now
144 }
145 await write($)
146 } finally {
147 mod.isRefreshing = false
148 }
149}
150
151export const register: Register = on => {
152 on('session.start', async ($, e, next) => {
153 const result = await next(e)
154 mod.id = await $.session.id()
155 await refresh($)
156 $.clock.every(POLL_MS, () => void refresh($))
157 return result
158 })
159
160 // Spins for the whole turn (thinking, tools, streaming), not just while text streams.
161 on('turn.start', async ($, e, next) => {
162 const result = await next(e)
163 const s = mod.shared
164 mod.turnTimer?.cancel()
165 mod.turnTimer = $.clock.every(TICK_MS, () => {
166 mod.frame++
167 if (mod.stream?.first !== undefined) mod.stream.ticks++
168 const st = mod.stream
169 const est = st?.ticks ? Math.round((st.chars / CHARS_PER_TOKEN) * 1000 / (st.ticks * TICK_MS)) : undefined
170 s.speed = { ...s.speed, spin: SPIN.charAt(mod.frame % SPIN.length), est }
171 void write($)
172 })
173 return result
174 })
175
176 on('turn.complete', async ($, e, next) => {
177 mod.turnTimer?.cancel()
178 mod.turnTimer = undefined
179 mod.shared.speed = { rate: mod.shared.speed?.rate }
180 await write($)
181 return next(e)
182 })
183
184 // Rate is first chunk → last chunk, as in tok-speed; subagent steps are left out.
185 on('turn.step', async function* ($, e, next) {
186 if (e.agentId) return yield* next(e)
187 const st = { first: undefined as number | undefined, chars: 0, ticks: 0 }
188 mod.stream = st
189
190 let result
191 try {
192 const stream = next(e)
193 let step = await stream.next()
194 while (!step.done) {
195 st.first ??= await $.clock.now()
196 if (step.value.kind === 'text' || step.value.kind === 'thinking') st.chars += step.value.text.length
197 yield step.value
198 step = await stream.next()
199 }
200 result = step.value
201 } finally {
202 mod.stream = undefined
203 }
204
205 const out = result.usage?.output_tokens ?? 0
206 const elapsed = st.first === undefined ? 0 : (await $.clock.now()) - st.first
207 if (out > 0 && elapsed > 0) mod.shared.speed = { ...mod.shared.speed, rate: Math.round((out * 1000) / elapsed), est: undefined }
208 await write($)
209 return result
210 })
211
212 // Only tools that always wait on the person: in auto mode a permission "ask" goes to the classifier.
213 on('tool.call', async ($, e, next) => {
214 if (e.tool !== 'AskUserQuestion' && e.tool !== 'ExitPlanMode') return next(e)
215 mod.shared.speed = { ...mod.shared.speed, blocked: true }
216 await write($)
217 try {
218 return await next(e)
219 } finally {
220 mod.shared.speed = { ...mod.shared.speed, blocked: undefined }
221 await write($)
222 }
223 })
224
225 // This checkout's dev servers, one row each: a link while running, red while down. The statusLine leaves them out.
226 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
227 const rows = await read($, apps)
228 if (e.props.hasSurvey || !rows.length) return next(e)
229 const { Box, Link, Text } = $.ui.resolve(e)
230 return (
231 <Box flexDirection="column">
232 {rows.map(({ name, port }) => (
233 <Text>
234 <Text color={port ? 'success' : 'error'}>● </Text>
235 <Text>{name.padEnd(18)}</Text>
236 {port ? <Link href={nodeAppUrl(name, port)}>{nodeAppUrl(name, port)}</Link> : <Text color="error">down</Text>}
237 </Text>
238 ))}
239 </Box>
240 )
241 })
242}
243hooks/statusline/collect.ts 167 lines1import type { Git, Io, Pr } from './types'
2
3const out = async (io: Io, argv: string[], cwd: string) => (await io.run(argv, cwd)).stdout.trim()
4
5/** git@host:owner/repo.git, ssh://git@host/owner/repo or https://host/owner/repo.git → https://host/owner/repo. */
6export function webUrl(remote: string) {
7 const m = remote.match(/^(?:git@([^:]+):|ssh:\/\/(?:[^@]+@)?([^/:]+)(?::[0-9]+)?\/|https?:\/\/(?:[^@]+@)?([^/]+)\/)(.+?)(?:\.git)?\/?$/)
8 return m ? `https://${m[1] ?? m[2] ?? m[3]}/${m[4]}` : ''
9}
10
11export type TermVars = Partial<Record<'TMUX' | 'TERM_PROGRAM' | 'TERM' | 'VTE_VERSION' | 'WT_SESSION' | 'KONSOLE_VERSION', string>>
12
13/** Whether the terminal draws OSC 8 links; elsewhere the escapes print as text. Inside tmux it is the
14 * client's terminal that matters, named by `tmux display -p '#{client_termname}'`. */
15export function osc8Supported(v: TermVars, tmuxClientTerm: string) {
16 const terms = /ghostty|kitty|wezterm|iterm|alacritty|foot|rio|contour/i
17 if (v.TMUX) return terms.test(tmuxClientTerm)
18 return (
19 ['ghostty', 'iTerm.app', 'WezTerm', 'rio', 'vscode', 'Hyper'].includes(v.TERM_PROGRAM ?? '') ||
20 terms.test(v.TERM ?? '') ||
21 Number(v.VTE_VERSION ?? 0) >= 5000 ||
22 !!v.WT_SESSION ||
23 !!v.KONSOLE_VERSION
24 )
25}
26
27/** Git state of `cwd`, or undefined when `cwd` is not a repo root (as statusline.sh checks). */
28export async function collectGit(io: Io, cwd: string): Promise<Git | undefined> {
29 if (!cwd || !(await io.exists(`${cwd}/.git`))) return undefined
30
31 const branch = await out(io, ['git', '-c', 'core.useBuiltinFSMonitor=false', 'branch', '--show-current'], cwd)
32 // Both absolute: a bare --git-dir is ".git" at a repo root and would misflag every repo as a worktree.
33 const gitDir = await out(io, ['git', 'rev-parse', '--path-format=absolute', '--git-dir'], cwd)
34 const gitCommon = await out(io, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], cwd)
35
36 let isWorktree = false
37 let wtName = ''
38 if (gitDir && gitCommon && gitDir !== gitCommon) {
39 isWorktree = true
40 const m = cwd.match(/(\/\.claude\/worktrees\/|\/\.worktrees\/|\/\.worktree\/)([^/]+)(\/|$)/)
41 wtName = m ? m[2]! : gitDir.split('/').pop()!
42 }
43
44 let ahead = 0
45 let behind = 0
46 const upstream = await out(io, ['git', 'rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
47 if (upstream) [ahead, behind] = upstream.split(/\s+/).map(n => Number(n) || 0) as [number, number]
48
49 // " N files changed, A insertions(+), D deletions(-)": either count may be absent.
50 const shortstat = await out(io, ['git', 'diff', '--shortstat', 'HEAD'], cwd)
51 const added = Number(shortstat.match(/([0-9]+) insertion/)?.[1] ?? 0)
52 const removed = Number(shortstat.match(/([0-9]+) deletion/)?.[1] ?? 0)
53
54 const [epoch = '', sha = ''] = (await out(io, ['git', 'log', '-1', '--format=%ct %H'], cwd)).split(' ')
55 const commitEpoch = Number(epoch) || 0
56 const web = webUrl(await out(io, ['git', 'remote', 'get-url', 'origin'], cwd))
57
58 return { branch, gitCommon, isWorktree, wtName, ahead, behind, added, removed, commitEpoch, sha, web }
59}
60
61/**
62 * The base `branch` was most plausibly cut from: the candidate whose merge-base is fewest commits
63 * behind HEAD. Semver release branches are tried first so they win exact-distance ties.
64 */
65export async function collectBaseRef(io: Io, cwd: string, branch: string) {
66 if (!branch) return ''
67 const releases = (await out(io, ['git', 'branch', '-r', '--list', 'origin/release/*', '--sort=-version:refname'], cwd))
68 .split('\n')
69 .map(r => r.trim())
70 .filter(r => /\/release\/[0-9]+\.[0-9]+\.[0-9]+$/.test(r))
71 .slice(0, 5)
72 let baseRef = ''
73 let best = -1
74 for (const ref of [...releases, 'origin/stg', 'origin/main', 'origin/master']) {
75 const mb = await out(io, ['git', 'merge-base', ref, 'HEAD'], cwd)
76 if (!mb) continue
77 const dist = await out(io, ['git', 'rev-list', '--count', `${mb}..HEAD`], cwd)
78 if (!dist) continue
79 if (best < 0 || Number(dist) < best) {
80 best = Number(dist)
81 baseRef = ref.replace(/^origin\//, '')
82 }
83 }
84 return baseRef
85}
86
87export async function collectPr(io: Io, cwd: string, branch: string): Promise<Pr> {
88 const json = await out(io, ['gh', 'pr', 'view', branch, '--json', 'url,state,isDraft,reviewDecision'], cwd)
89 try {
90 const p = JSON.parse(json)
91 return { url: p.url ?? '', state: p.state ?? '', draft: p.isDraft === true, review: p.reviewDecision ?? '' }
92 } catch {
93 return { url: '', state: '', draft: false, review: '' }
94 }
95}
96
97export async function collectCi(io: Io, cwd: string, branch: string): Promise<'' | 'fail' | 'pending' | 'pass'> {
98 // gh exits non-zero while checks are pending; its JSON is still on stdout.
99 const json = await out(io, ['gh', 'pr', 'checks', branch, '--json', 'name,state,conclusion'], cwd)
100 let checks: { state?: string; conclusion?: string }[]
101 try {
102 checks = JSON.parse(json)
103 } catch {
104 return ''
105 }
106 if (!Array.isArray(checks) || !checks.length) return ''
107 const count = (f: (c: { state?: string; conclusion?: string }) => boolean) => checks.filter(f).length
108 if (count(c => ['FAILURE', 'CANCELLED', 'TIMED_OUT'].includes(c.conclusion ?? ''))) return 'fail'
109 if (count(c => ['PENDING', 'QUEUED', 'IN_PROGRESS'].includes(c.state ?? ''))) return 'pending'
110 if (count(c => ['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(c.conclusion ?? ''))) return 'pass'
111 return ''
112}
113
114const NESTED_WORKTREE = /^\/(?:\.claude\/worktrees|\.worktrees|\.worktree)\//
115
116function inProject(text: string, cwd: string) {
117 const i = text.indexOf(cwd)
118 return i >= 0 && !NESTED_WORKTREE.test(text.slice(i + cwd.length))
119}
120
121/** Node servers listening from inside `cwd`, as sorted "name:port" entries, one per name. */
122export async function collectNodeApps(io: Io, cwd: string) {
123 const lsof = (await io.run(['lsof', '-iTCP', '-sTCP:LISTEN', '-P', '-n'], cwd)).stdout
124 const listening = [
125 ...new Set(
126 lsof
127 .split('\n')
128 .map(l => l.trim().split(/\s+/))
129 .filter(c => c[0] === 'node' && /^[0-9]+$/.test(c[1] ?? ''))
130 .map(c => `${c[1]} ${c[8] ?? ''}`),
131 ),
132 ].sort()
133
134 const entries: string[] = []
135 for (const row of listening) {
136 const [pid, addr = ''] = row.split(' ') as [string, string?]
137 const port = addr.match(/[0-9]+$/)?.[0]
138 if (!port) continue
139 const args = (await io.run(['ps', '-p', pid, '-o', 'args='], cwd)).stdout.trim()
140 const procCwd =
141 (await io.run(['lsof', '-p', pid, '-a', '-d', 'cwd', '-Fn'], cwd)).stdout
142 .split('\n')
143 .find(l => l.startsWith('n/'))
144 ?.slice(1) ?? ''
145 // Only apps belonging to the current project directory, and not to a worktree nested inside it.
146 if (!inProject(args, cwd) && !inProject(procCwd, cwd)) continue
147 const combined = `${args} ${procCwd}`
148 let name =
149 combined.match(/.*apps\/backend\/([^/ ]*)/)?.[1] ??
150 combined.match(/.*apps\/frontend\/([^/]*)/)?.[1] ??
151 args.match(/.*nx\.js run ([^:]*):/)?.[1] ??
152 ''
153 // Plain Next.js/Vite projects are named after the directory, so their server still shows.
154 if (!name) name = cwd.split('/').pop() ?? ''
155 if (name) entries.push(`${name}:${port}`)
156 }
157 const seen = new Set<string>()
158 return entries
159 .sort((a, b) => a.split(':')[0]!.localeCompare(b.split(':')[0]!) || a.localeCompare(b))
160 .filter(e => {
161 const name = e.split(':')[0]!
162 if (seen.has(name)) return false
163 seen.add(name)
164 return true
165 })
166}
167hooks/statusline/render.ts 340 lines1import type { Git, Limit, Opts, Pr, Speed, State } from './types'
2
3// The terminal's own 16-colour slots, so the line follows its theme in light and dark.
4const ACCENT = '\x1b[0;35m'
5const WHITE = '\x1b[0;39m'
6const WARN = '\x1b[0;33m'
7const DIM = '\x1b[0;2;39m'
8const ADD = '\x1b[0;32m'
9const DEL = '\x1b[0;31m'
10const RESET = '\x1b[0m'
11
12const SEP = `${DIM} ⋮ ${RESET}`
13
14/** printf "%.0f": ties go to the even neighbour. */
15export function round(x: number) {
16 const f = Math.floor(x)
17 const d = x - f
18 return d > 0.5 ? f + 1 : d < 0.5 ? f : f % 2 === 0 ? f : f + 1
19}
20
21export function formatTokens(t: number) {
22 if (t >= 1e6) return `${(t / 1e6).toFixed(1)}M`
23 if (t >= 1000) return `${Math.floor(t / 1000)}k`
24 return `${t}`
25}
26
27export function truncate(s: string, max: number) {
28 const chars = Array.from(s)
29 return chars.length > max ? `${chars.slice(0, max).join('')}…` : s
30}
31
32export function formatReset(resets: string, now: number) {
33 if (!/^[0-9]+$/.test(resets)) return ''
34 const remaining = Number(resets) - now
35 if (remaining <= 0) return 'now'
36 if (remaining < 60) return '<1m'
37 if (remaining < 3600) return `${Math.floor(remaining / 60)}m`
38 const h = Math.floor(remaining / 3600)
39 const m = Math.floor((remaining % 3600) / 60)
40 return m > 0 ? `${h}h${m}m` : `${h}h`
41}
42
43function oscLink(url: string, text: string, opts: Opts) {
44 return opts.osc8 ? `\x1b]8;;${url}\x07${text}\x1b]8;;\x07` : text
45}
46
47// ━ is box-drawing, which the terminal draws itself: crisp at any font size, unlike ▰▱.
48function meter(pct: number, fill: string) {
49 const filled = Math.min(10, Math.floor((pct * 10 + 50) / 100))
50 return `${fill}${'━'.repeat(filled)}${DIM}${'━'.repeat(10 - filled)}${RESET}`
51}
52
53export function ciGlyph(kind: '' | 'fail' | 'pending' | 'pass') {
54 return kind === 'fail' ? `${DEL}✗${RESET}` : kind === 'pending' ? `${WARN}⏳${RESET}` : kind === 'pass' ? `${ADD}✓${RESET}` : ''
55}
56
57function prStateColor(state: string, draft: boolean) {
58 if (state === 'MERGED') return ACCENT
59 if (state === 'OPEN' && draft) return DIM
60 if (state === 'CLOSED') return DIM
61 return ADD
62}
63
64function formatCommitAge(secs: number) {
65 let label: string
66 if (secs < 60) label = 'just now'
67 else if (secs < 3600) label = `${Math.floor(secs / 60)}m ago`
68 else if (secs < 86400) {
69 const h = Math.floor(secs / 3600)
70 const m = Math.floor((secs % 3600) / 60)
71 label = m > 0 ? `${h}h ${m}m ago` : `${h}h ago`
72 } else label = `${Math.floor(secs / 86400)}d ago`
73 return `${DIM}${label}${RESET}`
74}
75
76export function shortModel(full: string) {
77 let short = full
78 const m = full.match(/Claude ([0-9.]+ )?(.+)/)
79 if (m) {
80 const version = m[1] ?? ''
81 short = `${m[2]}${version ? ` ${version.replace(/ $/, '')}` : ''}`
82 }
83 short = short.replace(/ \(1M context\)$/, '')
84 const c = short.match(/^([A-Za-z])[A-Za-z]* ([0-9.]+)$/)
85 return c ? `${c[1]!.toLowerCase()}${c[2]}` : short
86}
87
88function effortDisplay(level: string) {
89 if (!level) return ''
90 const e = level === 'xhigh' ? '(xh)' : level === 'max' ? '(max)' : `(${level[0]})`
91 return `${ACCENT}${e}${RESET}`
92}
93
94// Its own row, so nothing after it shifts as it changes.
95function speedDisplay(s: Speed | undefined) {
96 if (!s) return ''
97 if (s.blocked) return `${DEL}⏸ needs you${RESET}`
98 const n = s.est !== undefined ? `~${s.est}` : s.rate
99 if (s.spin) return `${WARN}⚡ ${s.spin}${n !== undefined ? ` ${n} tok/s` : ''}${RESET}`
100 return s.rate !== undefined ? `${WARN}⚡ ${s.rate} tok/s${RESET}` : `${DIM}⚡ – tok/s${RESET}`
101}
102
103function prBadge(pr: Pr, ci: string, opts: Opts) {
104 const number = pr.url.slice(pr.url.lastIndexOf('/') + 1)
105 const suffix =
106 pr.state === 'MERGED' ? ' merged' : pr.state === 'CLOSED' ? ' closed' : pr.state === 'OPEN' && pr.draft ? ' draft' : ''
107 let review = ''
108 if (pr.state === 'OPEN' && !pr.draft) {
109 if (pr.review === 'APPROVED') review = `${ADD}approved${RESET}`
110 else if (pr.review === 'CHANGES_REQUESTED') review = `${WARN}changes${RESET}`
111 }
112 let badge = `${prStateColor(pr.state, pr.draft)}${oscLink(pr.url, `#${number}`, opts)}${suffix}${RESET}`
113 if (review) badge += ` ${review}`
114 if (pr.state === 'OPEN' && ci) badge += ` ${oscLink(`${pr.url}/checks`, ci, opts)}`
115 return badge
116}
117
118function line2(s: State, opts: Opts) {
119 const g = s.git
120 const branch = g?.branch ?? ''
121 let isWorktree = g?.isWorktree ?? false
122 let wtName = g?.wtName ?? ''
123 // Path-based detection runs even when git detection fails.
124 if (!isWorktree && s.cwd) {
125 const m = s.cwd.match(/^(.*)(\/\.claude\/worktrees\/|\/\.worktrees\/|\/\.worktree\/)([^/]+)/)
126 if (m) {
127 isWorktree = true
128 wtName = m[3]!
129 }
130 }
131
132 const pr = branch && s.pr?.url ? s.pr : undefined
133 const badge = pr ? prBadge(pr, s.ci, opts) : ''
134
135 let wtPr = ''
136 if (branch) {
137 if (isWorktree && wtName) {
138 if (badge) wtPr = `${prStateColor(pr!.state, pr!.draft)}⎇${RESET} ${badge}`
139 else {
140 // A worktree named after its branch needs no ⎇ label.
141 const norm = (x: string) => x.replace(/[/-]/g, '').toLowerCase()
142 if (norm(branch) !== norm(wtName)) wtPr = `${DIM}⎇ ${truncate(wtName, 45)}${RESET}`
143 }
144 } else if (badge) wtPr = badge
145 }
146
147 const sync = g
148 ? [g.ahead > 0 ? `${WHITE}↑${g.ahead}${RESET}` : '', g.behind > 0 ? `${WHITE}↓${g.behind}${RESET}` : ''].filter(Boolean).join(' ')
149 : ''
150 const lines = g
151 ? [g.added > 0 ? `${ADD}+${g.added}${RESET}` : '', g.removed > 0 ? `${DEL}-${g.removed}${RESET}` : ''].filter(Boolean).join(' ')
152 : ''
153 const web = g?.web ?? ''
154 const link = (path: string, text: string) => (web ? oscLink(`${web}${path}`, text, opts) : text)
155 const base = branch && s.baseRef && s.baseRef !== branch ? link(`/compare/${s.baseRef}...${branch}`, `${DIM}← ${DIM}${s.baseRef}${RESET}`) : ''
156 const age =
157 g && g.commitEpoch > 0 && opts.now - g.commitEpoch < 18000 ? link(`/commit/${g.sha}`, formatCommitAge(opts.now - g.commitEpoch)) : ''
158 const branchText = link(`/tree/${branch}`, `${ACCENT}${truncate(branch, 45)}${RESET}`)
159
160 let out = ''
161 if (branch) {
162 if (isWorktree) {
163 if (wtPr) out += wtPr
164 if (out) out += ' '
165 out += branchText
166 if (base) out += ` ${base}`
167 if (sync) out += (out ? SEP : '') + sync
168 } else {
169 out += branchText
170 if (wtPr) out += ` ${wtPr}`
171 if (base) out += ` ${base}`
172 if (sync) out += SEP + sync
173 }
174 const repo = isWorktree && g?.gitCommon ? g.gitCommon.replace(/\/\.git$/, '').split('/').pop()! : s.cwd.split('/').pop()!
175 if (repo) out = `${link('', `${WHITE}${truncate(repo, 30)}${RESET}`)}${SEP}${out}`
176 }
177 if (!out && isWorktree && wtName) out = `${DIM}⎇ ${truncate(wtName, 45)}${RESET}`
178 if (lines) out += (out ? SEP : '') + lines
179 if (age) out += (out ? SEP : '') + age
180 return out
181}
182
183/** The apps a checkout is expected to run, by the directory that holds each. */
184export const KNOWN_APPS: Record<string, string> = {
185 'client-api': 'apps/backend/client-api',
186 'patient-navigator': 'apps/frontend/patient-navigator',
187 'provider-portal': 'apps/frontend/provider-portal',
188 yoda: 'apps/frontend/yoda',
189}
190
191export function nodeAppUrl(name: string, port: string) {
192 switch (name) {
193 case 'client-api': return `https://dev.api.nestgenomics.com:${port}/graphql`
194 case 'yoda': return `https://dev.yoda.nestgenomics.com:${port}`
195 case 'patient-navigator': return `https://dev.app.nestgenomics.com:${port}`
196 case 'provider-portal': return `https://dev.portal.nestgenomics.com:${port}`
197 default: return `http://localhost:${port}`
198 }
199}
200
201function line3(s: State, opts: Opts) {
202 const limits = ([['S', s.rate5h], ['W', s.rate7d]] as [string, Limit][]).flatMap(([label, l]) => {
203 if (l.pct === -1) return []
204 const p = round(l.pct)
205 if (p < 80) return []
206 const reset = l.resets && l.resets !== 'null' ? ` ${DIM}↻${formatReset(l.resets, opts.now)}${RESET}` : ''
207 return [oscLink('https://claude.ai/settings/usage', `${DIM}${label}${RESET} ${meter(p, DEL)} ${DEL}${p}%${RESET}${reset}`, opts)]
208 })
209 const apps = s.nodeApps.length
210 ? `${ADD}${s.nodeApps
211 .map(entry => {
212 const i = entry.lastIndexOf(':')
213 const port = entry.slice(i + 1)
214 return /^[0-9]+$/.test(port) ? oscLink(nodeAppUrl(entry.slice(0, i), port), entry, opts) : entry
215 })
216 .join(' ')}${RESET}`
217 : ''
218 return [limits.join(SEP), apps].filter(Boolean).join(SEP)
219}
220
221// Display columns of one code point; a zero-width one (U+FE0F) is 0 here.
222function wcwidth(cp: number) {
223 if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)) return 0
224 const wide: [number, number][] = [
225 [0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe],
226 [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab],
227 [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f5],
228 [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
229 [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf],
230 [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
231 [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff], [0x20000, 0x3fffd],
232 ]
233 return wide.some(([a, b]) => cp >= a && cp <= b) ? 2 : 1
234}
235
236const ESCAPES = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g
237
238/** Display columns of a line, ANSI and OSC 8 excluded; U+FE0F widens its base to emoji width. */
239export function visibleWidth(s: string) {
240 let w = 0
241 for (const ch of s.replace(ESCAPES, '')) {
242 const cp = ch.codePointAt(0)!
243 w += cp === 0xfe0f ? 1 : wcwidth(cp)
244 }
245 return w
246}
247
248/** Clamps a line to `max` columns: escapes copied, an open link closed, … in the last column. */
249export function fitLine(s: string, max: number) {
250 if (max <= 0 || visibleWidth(s) <= max) return s
251 const lim = max - 1
252 const chars = Array.from(s)
253 let out = ''
254 let dw = 0
255 let oscOpen = false
256 for (let i = 0; i < chars.length; ) {
257 if (chars[i] === '\x1b') {
258 let j = i + 1
259 if (chars[j] === ']') {
260 while (j < chars.length && chars[j] !== '\x07') j++
261 const seq = chars.slice(i, j + 1).join('')
262 out += seq
263 oscOpen = seq !== '\x1b]8;;\x07'
264 } else {
265 while (j < chars.length && !/[A-Za-z]/.test(chars[j]!)) j++
266 out += chars.slice(i, j + 1).join('')
267 }
268 i = j + 1
269 continue
270 }
271 const cw = Math.max(1, wcwidth(chars[i]!.codePointAt(0)!))
272 if (dw + cw > lim) break
273 out += chars[i]
274 dw += cw
275 i++
276 }
277 if (oscOpen) out += '\x1b]8;;\x07'
278 return `${out}${RESET}…`
279}
280
281/** The statusline's text, every line newline-terminated, as statusline.sh prints it. */
282export function render(s: State, opts: Opts) {
283 let hidden = false
284 try {
285 hidden = new RegExp(opts.hideModelRegex).test(s.model)
286 } catch {
287 // An invalid pattern hides nothing, as a failed [[ =~ ]] does.
288 }
289 const effort = effortDisplay(s.effort)
290 const modelSegment = [hidden ? '' : `${DIM}${shortModel(s.model)}${RESET}`, effort].filter(Boolean).join(' ')
291
292 const pct = round(s.ctxPct)
293 const [barColor, pctColor, warn] = pct >= 80 ? [DEL, DEL, ' ⚠️'] : pct >= 50 ? [WARN, WARN, ''] : [ACCENT, WHITE, '']
294 // Padded to fixed widths so the line holds still while they change; everything after them would shift.
295 const tokens = s.ctxCurrent > 0 ? ` ${DIM}(${formatTokens(s.ctxCurrent).padStart(4)})${RESET}` : ''
296 const contextBar = `${meter(pct, barColor)} ${pctColor}${String(pct).padStart(3)}%${RESET}`
297 let cache = ''
298 if (s.ctxCurrent > 0 && s.ctxCacheRead > 0) {
299 const cachePct = Math.floor((s.ctxCacheRead * 100) / s.ctxCurrent)
300 if (cachePct < 80) cache = ` ${DIM}⚡${cachePct}%${RESET}`
301 }
302 const speed = speedDisplay(s.speed)
303
304 // Optional segments shed on a narrow pane: cache %, then token count.
305 const line1 = (incTok: boolean, incCache: boolean) => {
306 let l = `${WHITE}${`$${s.cost.toFixed(2)}`.padStart(6)}${RESET}`
307 if (modelSegment) l = `${modelSegment}${SEP}${l}`
308 l += `${SEP}${contextBar}`
309 if (incTok) l += tokens
310 l += warn
311 if (incCache) l += cache
312 return l
313 }
314
315 const l2 = line2(s, opts)
316 const l3 = line3(s, opts)
317
318 if (opts.oneLine) {
319 let one = line1(true, true)
320 if (l2) one += SEP + l2
321 if (l3) one += SEP + l3
322 // Too wide falls back to multi-line: a clipped one-liner is worse than two short lines.
323 if (!(opts.cols > 0 && visibleWidth(one) > opts.cols)) return [one, speed].filter(Boolean).map(l => `${l}\n`).join('')
324 }
325
326 let l1 = line1(true, true)
327 let f2 = l2
328 let f3 = l3
329 if (opts.cols > 0) {
330 for (const [tok, cache_] of [[true, true], [true, false], [false, false]] as const) {
331 l1 = line1(tok, cache_)
332 if (visibleWidth(l1) <= opts.cols) break
333 }
334 l1 = fitLine(l1, opts.cols)
335 if (f2) f2 = fitLine(f2, opts.cols)
336 if (f3) f3 = fitLine(f3, opts.cols)
337 }
338 return [l1, speed, f2, f3].filter(Boolean).map(l => `${l}\n`).join('')
339}
340hooks/statusline/types.ts 65 lines1export type Run = (argv: string[], cwd?: string) => Promise<{ exitCode: number; stdout: string }>
2
3export type Io = {
4 run: Run
5 exists: (path: string) => Promise<boolean>
6}
7
8/** A plan window as the statusline payload has it: `pct` -1 when absent; `resets` epoch seconds as text. */
9export type Limit = { pct: number; resets: string }
10
11export type Git = {
12 branch: string
13 gitCommon: string
14 isWorktree: boolean
15 wtName: string
16 ahead: number
17 behind: number
18 added: number
19 removed: number
20 /** Epoch seconds of HEAD's commit; 0 when unknown. */
21 commitEpoch: number
22 sha: string
23 /** origin as a browsable https URL, for links; empty when it isn't one. */
24 web: string
25}
26
27export type Pr = { url: string; state: string; draft: boolean; review: string }
28
29/** `spin` through a turn, `est` while its text streams, `rate` once a reply has finished; `blocked` while a tool waits on the person. */
30export type Speed = { spin?: string; est?: number; rate?: number; blocked?: boolean }
31
32export type State = {
33 /** The payload's model.display_name, e.g. "Opus 4.8 (1M context)". */
34 model: string
35 effort: string
36 cost: number
37 ctxPct: number
38 ctxCurrent: number
39 ctxCacheRead: number
40 rate5h: Limit
41 rate7d: Limit
42 cwd: string
43 git?: Git
44 baseRef: string
45 pr?: Pr
46 /** CI glyph as rendered (ANSI included); empty for none. */
47 ci: string
48 /** "name:port" entries. */
49 nodeApps: string[]
50 speed?: Speed
51}
52
53/** What the mod collects for the statusLine command; `cwd` is the directory it was collected in. */
54export type Shared = Pick<State, 'cwd' | 'git' | 'baseRef' | 'pr' | 'ci' | 'nodeApps' | 'speed'> & { osc8?: boolean }
55
56export type Opts = {
57 /** 0 = unknown: nothing is shed or clamped. */
58 cols: number
59 oneLine: boolean
60 osc8: boolean
61 hideModelRegex: string
62 /** Epoch seconds. */
63 now: number
64}
65types/index.d.ts 9 lines1/** Dev servers drawn above the prompt; `port` absent for an app of this checkout that isn't running. */
2export type Apps = { name: string; port?: string }[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'statusline-mod': { apps: Apps }
7 }
8}
9