SLOPSHOPPER

statusline

Status line under the prompt on the terminal: model and effort, working directory and git state

newspinnerguardstatusprocesstimer
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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › Opus 5.5 /work/app ⟨Claude Code's own drawing⟩

Draws

Prompt hint
Opus 5.5 /work/app ⟨Claude Code's own drawing⟩
README

claude-mods

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.

Plugins

PluginWhat it does
meterBand above the prompt showing context, prompt cache, usage limits and cost; /meter opens a detailed metrics pane. Works in the terminal and the desktop app.
agent-graphPane graphing the session's running subagents: a left-to-right card graph in the desktop app, which opens it on the first subagent, and an indented tree in the terminal, opened with /agent-graph.
auto-handoffOnce the context passes 40% of a window over 200k tokens (50% of a smaller one), has a dedicated turn write a nine-section handoff, with the decisions, gotchas, conventions and open questions it names, into an Open Knowledge Format bundle at .auto-handoff/ in the project root, which git ignores by default; then clears the context and continues from the handoff, and shows each new conversation the bundle's index. Safe with several sessions in one project; `/auto-handoff on\off\status` switches it per session. No UI.
statuslineA styled row under the prompt's mode line, above the engine's hints: model (warning colour on a fallback), effort, directory, branch with a ● when dirty and ↑N↓N against the upstream. Terminal only; the desktop app shows the model in its own composer.

Install

git clone <repo-url> claude-mods
claude plugin marketplace add ./claude-mods
claude plugin install meter@claude-mods --scope user
claude plugin install agent-graph@claude-mods --scope user
claude plugin install auto-handoff@claude-mods --scope user
claude plugin install statusline@claude-mods --scope user

Layout

claude-mods/
├── .claude-plugin/
│   └── marketplace.json          # marketplace name, owner, plugin list
└── plugins/
    └── <name>/
        ├── .claude-plugin/
        │   └── plugin.json       # name, version, description
        ├── hooks/                # hooks.json and the function-hooks modules
        ├── types/                # this plugin's exported types ("types" in plugin.json)
        └── tsconfig.json

Development

Installed plugins load in place from this checkout. After editing, run /reload-plugins or restart Claude Code. For live iteration without installing, run claude --plugin-dir plugins/<name>.

Check a plugin with:

claude plugin validate plugins/<name>
cd plugins/<name> && claude plugin test .

Check the marketplace with claude plugin validate . from the repo root.

License

MIT

Source 5 files
hooks/register.tsx 150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GitInfo, WorkspaceState } from '../types'
5import { DEFAULT_COLUMNS, HOST_INSET, drawLine, fitLine } from './line'
6import { EMPTY_MODEL, addAnswer, clean, keepSelection, resolveModel, selectModel } from './model'
7import { parseGitStatus } from './workspace'
8
9const modelAtom = atom({ plugin: 'statusline', key: 'model' } as const, EMPTY_MODEL)
10const workspaceAtom = atom({ plugin: 'statusline', key: 'workspace' } as const, { cwd: null, home: null, git: null } as WorkspaceState)
11
12const GIT_TIMEOUT_MS = 2000
13const TOOL_DEBOUNCE_MS = 1000
14const TREE_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
15
16let modelTicket = 0
17let workspaceTicket = 0
18let toolTimer: Timer | null = null
19
20async function refreshModel($: EngineInterface) {
21  const ticket = ++modelTicket
22  const at = await $.clock.now()
23  const id = clean(await $.session.model())
24  if (ticket !== modelTicket || id.trim() === '') return
25  await update($, modelAtom, m => selectModel(m, id, at))
26}
27
28async function readGit($: EngineInterface, cwd: string): Promise<GitInfo | null> {
29  try {
30    const run = await $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], {
31      cwd,
32      timeoutMs: GIT_TIMEOUT_MS,
33    })
34
35    return run.exitCode === 0 ? parseGitStatus(run.stdout) : null
36  } catch {
37    return null
38  }
39}
40
41async function refreshWorkspace($: EngineInterface) {
42  const ticket = ++workspaceTicket
43  if (!(await $.session.surfaces()).includes('terminal')) return
44  const cwd = clean(await $.session.cwd())
45  const home = (await $.env.get('HOME')) ?? null
46  const git = await readGit($, cwd)
47  if (ticket !== workspaceTicket) return
48  await update($, workspaceAtom, () => ({ cwd, home, git }))
49}
50
51const refresh = ($: EngineInterface) => {
52  void refreshModel($).catch(() => {})
53  void refreshWorkspace($).catch(() => {})
54}
55
56export const register: Register = on => {
57  on('session.start', async ($, e, next) => {
58    $.ui.status(undefined)
59    refresh($)
60
61    return next(e)
62  })
63
64  on('turn.start', async ($, e, next) => {
65    void refreshModel($).catch(() => {})
66
67    return next(e)
68  })
69
70  on('turn.complete', async ($, e, next) => {
71    const result = await next(e)
72    if (e.agentId === undefined) void refreshWorkspace($).catch(() => {})
73
74    return result
75  })
76
77  on('command.run', { command: 'model' }, async ($, e, next) => {
78    try {
79      return await next(e)
80    } finally {
81      void refreshModel($).catch(() => {})
82    }
83  })
84
85  on('session.end', async ($, e, next) => {
86    const isReset = e.reason === 'clear' || e.reason === 'resume'
87    if (isReset) await update($, modelAtom, keepSelection)
88    const result = await next(e)
89    if (isReset) void refreshModel($).catch(() => {})
90
91    return result
92  })
93
94  on('turn.step', async function* ($, e, next) {
95    const requestedAt = await $.clock.now()
96    const result = yield* next(e)
97    const usage = result.usage
98    if (e.agentId === undefined && usage) {
99      await update($, modelAtom, m =>
100        addAnswer(m, {
101          answered: usage.model,
102          requested: e.model,
103          effort: e.effort === undefined ? null : String(e.effort),
104          requestedAt,
105        }),
106      )
107    }
108
109    return result
110  })
111
112  on('tool.call', async ($, e, next) => {
113    try {
114      return await next(e)
115    } finally {
116      if (TREE_TOOLS.has(e.tool)) {
117        toolTimer?.cancel()
118        toolTimer = $.clock.after(TOOL_DEBOUNCE_MS, () => {
119          toolTimer = null
120          void refreshWorkspace($).catch(() => {})
121        })
122      }
123    }
124  })
125
126  on('classic.CwdChanged', async ($, e, next) => {
127    const result = await next(e)
128    void refreshWorkspace($).catch(() => {})
129
130    return result
131  })
132
133  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
134    const engine = await next(e)
135    if (e.surface !== 'terminal') return engine
136    const columns = (e.viewport?.columns ?? DEFAULT_COLUMNS) - 2 * HOST_INSET
137    const line = fitLine(resolveModel(await read($, modelAtom)), await read($, workspaceAtom), columns)
138    if (line.segments.length === 0) return engine
139    const els = $.ui.resolve(e)
140    const { Box } = els
141
142    return (
143      <Box key="statusline-hint" flexDirection="column">
144        {drawLine(els, line)}
145        {engine}
146      </Box>
147    )
148  })
149}
150
hooks/line.tsx 103 lines
1import type { Color, Elements, RenderElement } from 'claude-code'
2
3import type { WorkspaceState } from '../types'
4import type { ResolvedModel } from './model'
5import { basename, shortenPath, splitPath } from './workspace'
6
7export type Run = { text: string; color?: Color; dim?: boolean; bold?: boolean }
8type Segment = { key: 'model' | 'path' | 'git'; runs: Run[] }
9type Level = { gap: number; path: number | 'base' | null; effort: boolean; fallbackFrom: boolean; branchMax: number }
10
11export const HOST_INSET = 2
12export const DEFAULT_COLUMNS = 80
13
14const FULL: Level = { gap: 3, path: 48, effort: true, fallbackFrom: true, branchMax: 48 }
15
16const STEPS: Partial<Level>[] = [
17  {},
18  { gap: 2 },
19  { path: 28 },
20  { path: 'base' },
21  { effort: false, fallbackFrom: false },
22  { branchMax: 24 },
23  { path: null },
24  { branchMax: 12 },
25]
26
27const LEVELS: Level[] = STEPS.reduce<Level[]>((levels, step) => [...levels, { ...(levels.at(-1) ?? FULL), ...step }], [])
28
29const width = (s: string) => [...s].length
30
31const cut = (s: string, max: number) => (width(s) <= max ? s : `${[...s].slice(0, max - 1).join('')}…`)
32
33export const buildSegments = (model: ResolvedModel | null, ws: WorkspaceState, level: Level): Segment[] => {
34  const segments: Segment[] = []
35
36  if (model && model.name !== '') {
37    const runs: Run[] = []
38    if (model.fallbackFrom !== null) {
39      runs.push({ text: model.name, bold: true, color: 'warning' })
40      runs.push({ text: level.fallbackFrom ? ` fallback from ${model.fallbackFrom}` : ' fallback', dim: true })
41    } else {
42      runs.push({ text: model.name, bold: true })
43      if (level.effort && model.effort !== null && model.effort !== '') runs.push({ text: ` ${model.effort}`, dim: true })
44    }
45    segments.push({ key: 'model', runs })
46  }
47
48  if (ws.cwd !== null && ws.cwd !== '' && level.path !== null) {
49    const shown =
50      level.path === 'base' ? basename(shortenPath(ws.cwd, ws.home, Infinity)) : shortenPath(ws.cwd, ws.home, level.path)
51    const { parent, base } = splitPath(shown)
52    const runs: Run[] = []
53    if (parent !== '') runs.push({ text: parent, dim: true })
54    runs.push({ text: base })
55    segments.push({ key: 'path', runs })
56  }
57
58  if (ws.git !== null) {
59    const g = ws.git
60    const runs: Run[] = [{ text: cut(g.head, level.branchMax), color: g.isDetached ? 'warning' : undefined }]
61    if (g.isDirty) runs.push({ text: ' ' }, { text: '●' })
62    if (g.ahead > 0 || g.behind > 0) {
63      runs.push({ text: ' ' })
64      if (g.ahead > 0) runs.push({ text: `↑${g.ahead}` })
65      if (g.behind > 0) runs.push({ text: `↓${g.behind}`, color: 'warning' })
66    }
67    segments.push({ key: 'git', runs })
68  }
69
70  return segments
71}
72
73const rowWidth = (segments: readonly Segment[], gap: number) =>
74  segments.reduce((n, s) => n + s.runs.reduce((w, r) => w + width(r.text), 0), 0) + gap * Math.max(0, segments.length - 1)
75
76export const fitLine = (model: ResolvedModel | null, ws: WorkspaceState, columns: number) => {
77  for (const level of LEVELS) {
78    const segments = buildSegments(model, ws, level)
79    if (rowWidth(segments, level.gap) <= columns) return { segments, gap: level.gap }
80  }
81  const level = LEVELS.at(-1)!
82
83  return { segments: buildSegments(model, ws, level), gap: level.gap }
84}
85
86export const drawLine = (els: Elements['terminal'], line: ReturnType<typeof fitLine>): RenderElement => {
87  const { Box, Text } = els
88
89  return (
90    <Box key="statusline" marginTop={1} columnGap={line.gap} flexWrap="nowrap">
91      {line.segments.map(s => (
92        <Text key={s.key} wrap="truncate-end">
93          {s.runs.map((r, i) => (
94            <Text key={`r${i}`} color={r.color} dimColor={r.dim} bold={r.bold}>
95              {r.text}
96            </Text>
97          ))}
98        </Text>
99      ))}
100    </Box>
101  )
102}
103
hooks/model.ts 67 lines
1import type { ModelState } from '../types'
2
3const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g
4
5export const clean = (s: string) => {
6  const cut = s.replace(CONTROL, ' ').slice(0, 200)
7  const last = cut.charCodeAt(cut.length - 1)
8  return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
9}
10
11const capitalize = (s: string) => `${s[0]!.toUpperCase()}${s.slice(1).toLowerCase()}`
12
13export const modelName = (id: string) => {
14  const versioned = /(opus|sonnet|haiku|fable)-(\d{1,2})(?:-(\d{1,2})(?!\d)|(?=$|\[|-(?:\d{8}(?!\d)|v\d)))/i.exec(id)
15  if (versioned) return `${capitalize(versioned[1]!)} ${versioned[2]}${versioned[3] === undefined ? '' : `.${versioned[3]}`}`
16  const alias = /^(opus|sonnet|haiku|fable)(?:\[[^\]]*\])?$/i.exec(id)
17
18  return alias ? capitalize(alias[1]!) : id.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '')
19}
20
21export const EMPTY_MODEL: ModelState = {
22  selected: null,
23  selectedAt: 0,
24  answered: null,
25  requested: null,
26  effort: null,
27  answeredRequestedAt: 0,
28}
29
30export const selectModel = (m: ModelState, id: string, at: number): ModelState =>
31  m.selected === id ? m : { ...m, selected: id, selectedAt: at }
32
33export type Answer = { answered: string; requested: string; effort: string | null; requestedAt: number }
34
35export const addAnswer = (m: ModelState, a: Answer): ModelState => ({
36  ...m,
37  answered: clean(a.answered),
38  requested: clean(a.requested),
39  effort: a.effort === null ? null : clean(a.effort),
40  answeredRequestedAt: a.requestedAt,
41})
42
43export const keepSelection = (m: ModelState): ModelState => ({
44  ...EMPTY_MODEL,
45  selected: m.selected,
46  selectedAt: m.selectedAt,
47})
48
49export type ResolvedModel = { name: string; effort: string | null; fallbackFrom: string | null }
50
51const answerWins = (m: ModelState) =>
52  m.answered !== null &&
53  (m.selected === null ||
54    m.answeredRequestedAt >= m.selectedAt ||
55    (m.requested !== null && modelName(m.requested) === modelName(m.selected)))
56
57export const resolveModel = (m: ModelState): ResolvedModel | null => {
58  if (m.answered !== null && answerWins(m)) {
59    const name = modelName(m.answered)
60    const requested = m.requested === null ? null : modelName(m.requested)
61
62    return { name, effort: m.effort, fallbackFrom: requested !== null && requested !== name ? requested : null }
63  }
64
65  return m.selected === null ? null : { name: modelName(m.selected), effort: null, fallbackFrom: null }
66}
67
hooks/workspace.ts 57 lines
1import type { GitInfo } from '../types'
2import { clean } from './model'
3
4const SHORT_SHA = 7
5
6export const shortenPath = (cwd: string, home: string | null, max: number): string => {
7  const root = home === null ? '' : home.replace(/\/+$/, '')
8  const shown = root !== '' && (cwd === root || cwd.startsWith(`${root}/`)) ? `~${cwd.slice(root.length)}` : cwd
9  if (shown.length <= max) return shown
10
11  const segments = shown.split('/').filter(Boolean)
12  let kept = segments.slice(-1)
13  for (let i = segments.length - 2; i >= 0; i--) {
14    const next = [segments[i]!, ...kept]
15    if (`…/${next.join('/')}`.length > max) break
16    kept = next
17  }
18
19  return `…/${kept.join('/')}`
20}
21
22export const splitPath = (shown: string): { parent: string; base: string } => {
23  if (shown === '~' || shown === '/') return { parent: '', base: shown }
24  const cut = shown.lastIndexOf('/') + 1
25
26  return { parent: shown.slice(0, cut), base: shown.slice(cut) }
27}
28
29export const basename = (shown: string) => splitPath(shown).base
30
31export const parseGitStatus = (stdout: string): GitInfo | null => {
32  let oid = ''
33  let head = ''
34  let ahead = 0
35  let behind = 0
36  let isDirty = false
37  for (const line of stdout.split('\n')) {
38    if (line === '') continue
39    if (!line.startsWith('# ')) {
40      isDirty = true
41      continue
42    }
43    const [key, ...rest] = line.slice(2).split(' ')
44    if (key === 'branch.oid') oid = rest.join(' ')
45    else if (key === 'branch.head') head = rest.join(' ')
46    else if (key === 'branch.ab') {
47      ahead = Number(/^\+(\d+)$/.exec(rest[0] ?? '')?.[1] ?? 0)
48      behind = Number(/^-(\d+)$/.exec(rest[1] ?? '')?.[1] ?? 0)
49    }
50  }
51  const isDetached = head === '(detached)'
52  const name = isDetached ? `@${oid.slice(0, SHORT_SHA)}` : head
53  if (name === '' || name === '@') return null
54
55  return { head: clean(name), isDetached, isDirty, ahead, behind }
56}
57
types/index.d.ts 13 lines
1export type ModelState = { selected: string | null; selectedAt: number; answered: string | null; requested: string | null; effort: string | null; answeredRequestedAt: number }
2export type GitInfo = { head: string; isDetached: boolean; isDirty: boolean; ahead: number; behind: number }
3export type WorkspaceState = { cwd: string | null; home: string | null; git: GitInfo | null }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'statusline': {
8      model: ModelState
9      workspace: WorkspaceState
10    }
11  }
12}
13