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

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 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
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.
statusline.claude-.hooks/hooks.json carries a description key.hooks/*.json whose values are all lists into settings.json as settings hooks.modules stays out of the user's settings..claude-plugin/plugin.json has no $schema.$schema is Claude Code's plugin schema..claude-plugin/plugin.json makes apm treat this folder as a Claude plugin (package_type: marketplace_plugin).~/.claude/skills/, which is where Claude Code adopts skill plugins.hooks/register.tsx 93 lines1import { 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}
93hooks/format.ts 64 lines1import 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}
64types/index.d.ts 14 lines1export 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