SLOPSHOPPER

statusline

Status on a line of its own below the prompt hint line: model @ effort | context used | cwd | worktree.

newspinnerprocesstimer
v0.1.0MITupdated 2026-10-08ngicks/agents-package/mods/statusline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ 🤖 Opus 5.5 | 🔋 49% used | 📂 /work/app

Draws

Prompt hint
⟨Claude Code's own drawing⟩ 🤖 Opus 5.5 | 🔋 49% used | 📂 /work/app
README

statusline

A Claude Code mod that draws a status on a line of its own below the hint line under the prompt:

  ⏵⏵ auto mode on (shift+tab to cycle)
  🤖 Opus 5.5 (1M context) @ medium | 🔋 7% used | 📂 /home/u/src/agents-package/main | ⛕ main

The cwd is cut from the left so the whole line fits the terminal width. SKILL.md describes each component.

Install

Install it globally with apm:

apm install -g ngicks/agents-package/mods/statusline

apm copies this folder to ~/.claude/skills/statusline/. Claude Code adopts it as the plugin statusline@skills-dir in the next session. Run /reload-plugins to load it into a running session.

For a one-off session from a checkout:

claude --plugin-dir ./mods/statusline

Develop

claude plugin validate mods/statusline
claude plugin test mods/statusline

Claude Code writes the API types to .claude-plugin/types/ whenever it loads the mod from this folder. After that, tsc -p mods/statusline type-checks it.

Layout notes

  • The folder, the skill and the plugin are all named statusline.
  • Claude Code reserves plugin names starting with claude-.
  • hooks/hooks.json carries a description key.
  • apm merges any hooks/*.json whose values are all lists into settings.json as settings hooks.
  • A string value stops that merge, so modules stays out of the user's settings.
  • .claude-plugin/plugin.json has no $schema.
  • apm 0.29.0 refuses a manifest whose $schema is Claude Code's plugin schema.
  • .claude-plugin/plugin.json makes apm treat this folder as a Claude plugin (package_type: marketplace_plugin).
  • apm then deploys the folder whole into ~/.claude/skills/, which is where Claude Code adopts skill plugins.
Source 3 files
hooks/register.tsx 93 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import { formatStatusRow, modelDisplayName, worktreeNameOf } from './format'
4
5// /model and /effort change the line without starting a turn, so poll for them.
6const REFRESH_MS = 2000
7
8const status = atom({ plugin: 'statusline', key: 'parts' } as const, null)
9
10// Effort comes from two places: settings (what /effort saves) and each
11// model request (what was actually sent). Whichever changed last wins.
12let effort: string | undefined
13let settingsEffort: unknown
14let worktree: { cwd: string; name: string | undefined } | undefined
15
16async function worktreeOf($: EngineInterface, cwd: string) {
17  if (worktree?.cwd === cwd) return worktree.name
18  let name: string | undefined
19  try {
20    const r = await $.process.run(['git', 'rev-parse', '--absolute-git-dir'], { cwd, timeoutMs: 5000 })
21    name = r.exitCode === 0 ? worktreeNameOf(r.stdout.trim()) : undefined
22  } catch {
23    name = undefined
24  }
25  worktree = { cwd, name }
26  return name
27}
28
29async function refresh($: EngineInterface) {
30  const [model, usage, settings, cwd] = await Promise.all([
31    $.session.model(),
32    $.session.usage(),
33    $.settings.read(),
34    $.session.cwd(),
35  ])
36  const level = (settings as { effortLevel?: unknown }).effortLevel
37  if (level !== settingsEffort) {
38    settingsEffort = level
39    effort = typeof level === 'string' ? level : undefined
40  }
41  const parts = {
42    model: modelDisplayName(model),
43    effort,
44    percent: usage.context.percent,
45    cwd,
46    worktree: await worktreeOf($, cwd),
47  }
48  // A write redraws the hint line even when the value is unchanged.
49  if (JSON.stringify(await read($, status)) !== JSON.stringify(parts)) {
50    await update($, status, () => parts)
51  }
52}
53
54export const register: Register = (on) => {
55  on('session.start', async ($, e, next) => {
56    const result = await next(e)
57    await refresh($)
58    $.clock.every(REFRESH_MS, () => void refresh($))
59    return result
60  })
61
62  on('turn.complete', async ($, e, next) => {
63    const result = await next(e)
64    await refresh($)
65    return result
66  })
67
68  on('turn.step', async function* ($, e, next) {
69    if (e.agentId === undefined) {
70      effort = e.effort === undefined ? undefined : String(e.effort)
71      const prev = await read($, status)
72      if (prev !== null && prev.effort !== effort) {
73        await update($, status, p => (p === null ? p : { ...p, effort }))
74      }
75    }
76    return yield* next(e)
77  })
78
79  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
80    const parts = await read($, status)
81    if (parts === null) return next(e)
82
83    const drawn = await next(e)
84    const { Box, Text } = $.ui.resolve(e)
85    return (
86      <Box flexDirection="column">
87        {drawn}
88        <Text dimColor>{formatStatusRow(parts, e.viewport?.columns)}</Text>
89      </Box>
90    )
91  })
92}
93
hooks/format.ts 64 lines
1import type { StatusParts } from '../types'
2
3const SEPARATOR = ' | '
4const ELLIPSIS = '…'
5const MODEL_LABEL = '🤖'
6const CONTEXT_LABEL = '🔋'
7const CONTEXT_LOW_LABEL = '🪫'
8const CONTEXT_LOW_AT = 60
9const CWD_LABEL = '📂'
10const WORKTREE_LABEL = '⛕'
11
12// The engine indents the hint line by two cells. Two more cells stay free at
13// the right end so the terminal never wraps the row.
14export const HINT_CHROME_WIDTH = 4
15
16export function formatStatusRow(parts: StatusParts, columns: number | undefined): string {
17  return formatStatusLine(parts, columns === undefined ? undefined : columns - HINT_CHROME_WIDTH)
18}
19
20export function modelDisplayName(id: string): string {
21  const bracket = /\[(\d+)([km])\]$/i.exec(id)
22  const base = bracket ? id.slice(0, bracket.index) : id
23  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?/i.exec(base)
24  if (!m) return id
25  const [, family = '', major = '', minor] = m
26  const name = `${family[0]?.toUpperCase() ?? ''}${family.slice(1)} ${minor === undefined ? major : `${major}.${minor}`}`
27  return bracket ? `${name} (${bracket[1]}${bracket[2]?.toUpperCase()} context)` : name
28}
29
30export function worktreeNameOf(gitDir: string): string | undefined {
31  const parts = gitDir.replace(/\/+$/, '').split('/')
32  return parts.at(-2) === 'worktrees' ? parts.at(-1) : undefined
33}
34
35export function truncateStart(text: string, width: number): string {
36  const chars = Array.from(text)
37  if (chars.length <= width) return text
38  if (width <= 0) return ''
39  return ELLIPSIS + chars.slice(chars.length - (width - 1)).join('')
40}
41
42// Only the emoji labels are expected to be wide; ⛕ has no emoji presentation and takes one cell.
43export function cellWidth(text: string): number {
44  return Array.from(text).reduce((sum, c) => sum + ((c.codePointAt(0) ?? 0) >= 0x1f000 ? 2 : 1), 0)
45}
46
47export function contextLabel(percent: number): string {
48  return percent < CONTEXT_LOW_AT ? CONTEXT_LABEL : CONTEXT_LOW_LABEL
49}
50
51export function formatStatusLine(parts: StatusParts, width?: number): string {
52  const percent = parts.percent ?? 0
53  const head = `${MODEL_LABEL} ${parts.effort === undefined ? parts.model : `${parts.model} @ ${parts.effort}`}`
54  const used = `${contextLabel(percent)} ${percent}% used`
55  const tail = parts.worktree === undefined ? [] : [`${WORKTREE_LABEL} ${parts.worktree}`]
56  const cwdPrefix = `${CWD_LABEL} `
57  if (width === undefined) return [head, used, cwdPrefix + parts.cwd, ...tail].join(SEPARATOR)
58
59  const fixed = [head, used, ...tail]
60  const fixedWidth = fixed.reduce((sum, s) => sum + cellWidth(s), 0) + SEPARATOR.length * fixed.length
61  const cwd = truncateStart(parts.cwd, width - fixedWidth - cellWidth(cwdPrefix))
62  return (cwd === '' ? fixed : [head, used, cwdPrefix + cwd, ...tail]).join(SEPARATOR)
63}
64
types/index.d.ts 14 lines
1export type StatusParts = {
2  model: string
3  effort?: string
4  percent?: number
5  cwd: string
6  worktree?: string
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    statusline: { parts: StatusParts | null }
12  }
13}
14