SLOPSHOPPER

statusline-mod

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

newbandguardprocesstimer
A shopper browsing a rack in a slop shop
README

dotfiles

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.

Features

  • Opinionated tool selection - Carefully curated applications and configurations that work well together
  • Cross-platform compatibility - Consistent experience across macOS, Ubuntu, Fedora, Arch Linux, and WSL
  • Environment-aware setup - Thoughtfully separated configurations for personal and work environments
  • Zero-decision installation - Smart system detection with guided setup—no overwhelming choices
  • Modular architecture - Component-based system with sensible defaults
  • Symlink-based configs - Centralized configuration management for easy updates
  • AI development tools - Curated AI assistant rules for Cursor

[!TIP] The system automatically detects your platform and environment, making installation straightforward across different setups.

Quick Start

YOLO It

curl -fsSL https://jordangarcia.me/dotfiles/boot.sh | bash

This will automatically clone/update the repo, fetch latest changes, and run interactive setup.

Interactive Installation (Recommended)

git clone https://github.com/whoisjordangarcia/dotfiles.git
cd dotfiles
./bin/dot -i              # Interactive setup

Alternative Methods

./bin/dot --system mac --work         # Direct installation
./bin/dot --system linux_ubuntu --personal

Headless Server / Proxmox / LXC

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)

Configuration Components

Shell & Terminal

Zsh (configs/zshrc/)

Modular Z shell configuration with organized .zshrc-modules/:

ModulePurpose
.zshrc.history50k command history with smart duplicate filtering, ignores mundane commands
.zshrc.aliasesreload, vim→nvim, ls→eza, cat→bat, top→btop, git shortcuts
.zshrc.functionsencode64/decode64, compress/decompress, web2app launcher creator
.zshrc.pluginsCompletions + plugins: zsh-syntax-highlighting, zsh-autosuggestions, nx-completion
.zshrc.envvarsEDITOR, pyenv, colored man pages via bat
.zshrc.vim-modeVi keybindings in shell
.zshrc.workWork-specific environment config

Key Aliases:

  • gcm - checkout main, gpo - pull origin, gpf - push --force-with-lease
  • wt-auto, wt-list, wt-remove - git worktree management
  • dotfiles - quick access to this repo
Tmux (configs/tmux/)

Terminal multiplexer with extensive customization:

  • Prefix: Ctrl+a (changed from default Ctrl+b)
  • Navigation: Vim-style with vim-tmux-navigator (Ctrl+h/j/k/l seamless pane switching)
  • Session Management: tmux-resurrect + tmux-continuum for persistence
  • UI: Elegant minimal statusline with git status, CPU, memory, battery

Custom Scripts (~/.tmux/scripts/):

ScriptFunction
pane_title.shGradient-colored pane borders by command (Claude=purple, nvim=green, nx=orange)
session_picker.shProcess-aware session selection with zoxide integration
cpu_usage.shCPU monitoring for statusline
memory_usage.shRAM usage display

Plugins via TPM:

  • vim-tmux-navigator, tmux-yank, tmux-resurrect, tmux-continuum
  • tmux-sessionx, tmux-floax, tmux-prefix-highlight, tmux-cpu, tmux-battery
Ghostty (configs/ghostty/)

Fast, GPU-accelerated terminal emulator:

  • Font: GohuFont 14 Nerd Font, 16px
  • Window: No decoration, blur radius 20px, transparent
  • Behavior: Mouse hide while typing, block cursor, clipboard access enabled
  • Integration: Shell integration for Zsh, xterm-256color with RGB
Starship (configs/starship/)

Minimal, fast shell prompt:

  • Format: Directory → git worktree indicator (🎯) → git branch/status → character
  • Git Status: Custom symbols for ahead (⇡), diverged (⇕), behind (⇣), conflicts, modifications
  • Character: Success (❯ cyan), Error (✗ bold cyan)
  • Performance: 200ms command timeout

Code Editors

Neovim (configs/nvim/)

LazyVim distribution with 27+ extras enabled:

AI & Coding:

  • claudecode - Claude Code integration
  • yanky - Enhanced clipboard manager

Languages:

  • Docker, Git, JSON, Markdown, Prisma, Python, Tailwind CSS, TypeScript, YAML

Editor Enhancements:

  • aerial - Code outline/symbols
  • harpoon2 - Quick file navigation
  • illuminate - Highlight word references
  • mini-diff - Inline git diffs
  • outline - Symbol explorer
  • treesitter-context - Shows code context at top

Additional:

  • DAP debugging with Neovim Lua support
  • ESLint + Prettier formatting
  • Integrated test runner
  • Lazy loading for fast startup
LazyGit (configs/lazygit/)

Terminal Git UI with custom commands:

  • c key: Create draft PR via gh pr create --web --draft
  • n key: New branch with type menu (feature/fix/hotfix/chore/docs/refactor)
  • Naming: jordan/{type}-{name}
  • Theme: Catppuccin Mocha (coral borders, blue text, dark background)
  • Features: Nerd Font icons, fuzzy filter, numstat display, commit signing

Window Managers

Rift (configs/rift/) - macOS

Tiling window manager with integrated SketchyBar:

  • Navigation: Alt+j/k/l/; for focus, Alt+Shift to move windows
  • Workspaces: Alt+1-0 for 10 workspaces
  • Modes: Visual resize mode, accordion layouts (vertical/horizontal)
  • Integration: Auto-hides dock and menu bar

SketchyBar Integration:

  • Dynamic status bar with workspace info
  • SbarLua for Lua scripting
  • Theme switching capability
  • Custom app font for icons
Hyprland (configs/hypr/) - Arch Linux

Modern Wayland compositor:

ConfigPurpose
hyprland.confMain entry, sources modular configs
monitors.confDisplay setup for retina 2x (5K/6K)
input.confKeyboard (US), touchpad settings
looknfeel.confWindow rounding (12px), gaps, dwindle layout

HyDE Integration: Uses HyDE distribution for theming and extended configs.

Desktop Theming (configs/gtk-3.0/, gtk-4.0/, kvantum/, qt6ct/) - Arch Linux

Linux 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):

AppWhat it is
BraveWeb browser
FirefoxWeb browser
GhosttyTerminal emulator
SpotifyMusic
LutrisGame launcher
NautilusGNOME file manager
PavucontrolVolume control
WaybarStatus bar
File picker dialogsxdg-desktop-portal-gtk

Qt apps (themed by qt6ct/qt6ct.conf + kvantum/kvantum.kvconfig):

AppWhat it is
DolphinKDE file manager
ArkArchive manager
Claude DesktopAppImage
SteamUses Qt underneath

Current theme choices:

  • GTK: Gruvbox-Retro (dark) with Gruvbox-Plus-Dark icons
  • Qt style: Kvantum with KvGnomeDark engine
  • Qt colors: darker.conf color scheme
  • Cursor: catppuccin-mocha-green-cursors

[!NOTE] HyDE's wallbash system dynamically generates a Wallbash-Gtk theme from wallpaper colors and sets it via gsettings. The theming/linux setup script overrides this back to Gruvbox-Retro for consistent readability.

Waybar (configs/waybar/) - Wayland Status Bar

Customizable panel for Hyprland:

Modules:

  • Left: Power menu, HyDE menu, clipboard, wallpaper switcher, theme selector, Spotify
  • Center: Clock, idle inhibitor
  • Right: Privacy, system tray, battery, backlight, network, audio, keybind hints

Styling:

  • Dark bar with pill-shaped module groupings
  • Nerd Font workspace icons
  • Wallbash color integration from HyDE

File Management

Dolphin (configs/dolphin/) - KDE File Manager

Feature-rich file manager for Arch Linux:

View Settings:

  • Details mode with 64px preview icons
  • Split view enabled, hidden files visible
  • Full path in address bar (editable)
  • Expandable folders, filter bar, zoom slider

Service Menus (right-click actions):

  • Git integration via dolphin-plugins
  • Copy path via wl-clipboard (Wayland)
  • Custom .desktop actions

AI Development

Claude Code (configs/claude/)

Claude Code IDE settings and configuration.


Additional Configs

DirectoryPurpose
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

Platform Profiles

macOS (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.

Arch Linux (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

Ubuntu (linux_ubuntu)

./bin/dot --system linux_ubuntu

Installs: Core dev tools via apt, Git/SSH, Zsh, Vim, Tmux, Node, LazyGit, Starship, Fastfetch, Brave

Fedora (linux_fedora)

./bin/dot --system linux_fedora

Installs: Git/Zsh/Vim/Tmux, i3wm, Polybar, LazyGit, Fonts, Starship, Fastfetch, Brave

Headless Server / Proxmox / LXC (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.


Architecture

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)

Configuration Management

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

Management Commands

bin/dot

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

Post-Installation

Tmux Plugins

tmux source ~/.tmux.conf   # Reload config
# Inside tmux: <prefix> + I to install plugins

Zsh 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

YubiKey Git Signing (Optional)

gpg --list-secret-keys --keyid-format=long  # Find key ID
# Provide during ./bin/dot -i or set manually

Development

Adding Components

  1. Create setup script: script/{component}/{platform}/setup.sh
  2. Add to installation array in script/{platform}_installation.sh
  3. Use logging from script/common/log.sh
  4. Place configs in configs/{component}/
  5. New components are automatically picked up from the installation arrays

Supporting New Platforms

  1. Add detection to bin/dot detect_system()
  2. Create script/{platform}_installation.sh
  3. Implement component scripts
  4. Choose package manager (brew/apt/dnf/pacman)

Troubleshooting

IssueSolution
Permission deniedDon't run with sudo - scripts handle elevation
Missing dependenciesRun platform installation script first
Config conflicts./bin/dot --reset-config to start fresh
Tmux plugins missingInside tmux: <prefix> + I

Tested across macOS, Arch Linux, Ubuntu, Fedora, and WSL. Fork and adapt for your needs.

Source 5 files
hooks/register.tsx 243 lines
1import { 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}
243
hooks/statusline/collect.ts 167 lines
1import 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}
167
hooks/statusline/render.ts 340 lines
1import 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}
340
hooks/statusline/types.ts 65 lines
1export 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}
65
types/index.d.ts 9 lines
1/** 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