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

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.
| Plugin | What it does | ||
|---|---|---|---|
meter | Band 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-graph | Pane 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-handoff | Once 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. |
statusline | A 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. |
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
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
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.
hooks/register.tsx 150 lines1import { 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}
150hooks/line.tsx 103 lines1import 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}
103hooks/model.ts 67 lines1import 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}
67hooks/workspace.ts 57 lines1import 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}
57types/index.d.ts 13 lines1export 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