Runs your configured statusLine command and draws its line above the prompt in the Claude desktop app

Small Claude Code mods: plugins of function hooks that change what Claude Code shows or does, in the terminal and in the desktop app's Code tab.
In a terminal Claude Code session:
/plugin install <mod> --marketplace enhki/claude-mods
Answer y to add the marketplace, then pick the user scope so the mod loads in every session, desktop ones included.
| Mod | What it does |
|---|---|
statusline-desktop | Draws your terminal status line above the prompt in the desktop app |
The desktop app doesn't run your statusLine command. This mod does: it picks the command the way Claude Code does (project .claude/settings.local.json, then .claude/settings.json, then ~/.claude/settings.json), pipes it the same JSON the terminal would (model, cwd, cost, context window, rate limits), and draws the first line it prints in the band above the prompt.
config-file) sets one; otherwise they map to the app's own theme colours.current_usage reads slightly lower than in the terminal (output tokens come through as 0).Clone, then add the clone itself as your marketplace so edits are live after /reload-plugins with no reinstall:
claude plugin marketplace add ~/repos/claude-mods
claude plugin install statusline-desktop@claude-mods --scope user
Check a mod before committing:
claude plugin validate mods/<mod>
claude plugin test mods/<mod>hooks/register.tsx 132 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as $, Register } from 'claude-code'
3
4import type { Segment } from '../types'
5import { buildInput, ghosttyIncludes, parseAnsi, parsePalette, pickCommand } from './line'
6
7const line = atom({ plugin: 'statusline-desktop', key: 'line' } as const, null as Segment[] | null)
8
9// The terminal refreshes its status line on its own; the clock and git parts
10// only need this tick between turns.
11const TICK_MS = 30_000
12
13let isRunning = false
14let isPending = false
15let home: string | null = null
16let configHome: string | null = null
17
18async function readOrNull($: $, path: string): Promise<string | null> {
19 try {
20 return await $.fs.read(path)
21 } catch {
22 return null
23 }
24}
25
26async function env($: $, name: string): Promise<string> {
27 const { stdout } = await $.process.run(['printenv', name])
28 return stdout.trim()
29}
30
31async function homeDir($: $): Promise<string> {
32 if (home === null) home = await env($, 'HOME')
33 return home
34}
35
36// The terminal's own ANSI palette, so the line keeps the colours it has there:
37// ghostty's config and the files it pulls in with `config-file`, in load order.
38async function terminalPalette($: $, h: string): Promise<string[]> {
39 if (configHome === null) configHome = (await env($, 'XDG_CONFIG_HOME')) || `${h}/.config`
40 const dir = `${configHome}/ghostty`
41 const main = (await readOrNull($, `${dir}/config`)) ?? ''
42 const texts = [main]
43 for (const path of ghosttyIncludes(main, dir)) texts.push((await readOrNull($, path)) ?? '')
44 return parsePalette(...texts)
45}
46
47async function runOnce($: $): Promise<void> {
48 const [sessionId, cwd, root, model, version, usage, h] = await Promise.all([
49 $.session.id(),
50 $.session.cwd(),
51 $.session.root(),
52 $.session.model(),
53 $.session.version(),
54 $.session.usage(),
55 homeDir($),
56 ])
57 const command = pickCommand(
58 await Promise.all([
59 readOrNull($, `${root}/.claude/settings.local.json`),
60 readOrNull($, `${root}/.claude/settings.json`),
61 readOrNull($, `${h}/.claude/settings.json`),
62 ]),
63 )
64 if (command === null) {
65 await update($, line, () => null)
66 return
67 }
68 const stdin = JSON.stringify(buildInput({ sessionId, cwd, root, model, version: version.version, usage }))
69 const { exitCode, stdout } = await $.process.run(['bash', '-c', command], { cwd, stdin, timeoutMs: 10_000 })
70 if (exitCode !== 0 && stdout.trim() === '') return
71 const palette = await terminalPalette($, h)
72 const firstLine = stdout.split('\n').find(l => l.trim() !== '') ?? ''
73 await update($, line, () => parseAnsi(firstLine, palette))
74}
75
76// One run at a time; a request while one runs queues exactly one more.
77async function refresh($: $): Promise<void> {
78 if (isRunning) {
79 isPending = true
80 return
81 }
82 isRunning = true
83 try {
84 do {
85 isPending = false
86 await runOnce($)
87 } while (isPending)
88 } catch (err) {
89 $.ui.log(`statusline-desktop: ${err instanceof Error ? err.message : String(err)}`)
90 } finally {
91 isRunning = false
92 }
93}
94
95export const register: Register = on => {
96 on('session.start', async ($, e, next) => {
97 const result = await next(e)
98 void refresh($)
99 $.clock.every(TICK_MS, () => void refresh($))
100 return result
101 })
102
103 on('turn.complete', async ($, e, next) => {
104 const result = await next(e)
105 void refresh($)
106 return result
107 })
108
109 on('tool.call', async ($, e, next) => {
110 const result = await next(e)
111 void refresh($)
112 return result
113 })
114
115 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116 // The terminal already draws the real status line under the prompt.
117 if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
118 const segments = await read($, line)
119 if (!segments || segments.length === 0) return next(e)
120 const { Box, Text } = $.ui.resolve(e)
121 return (
122 <Box flexDirection="row">
123 {segments.map((s, i) => (
124 <Text key={String(i)} color={s.color} bold={s.bold} dimColor={s.dim} wrap="truncate-end">
125 {s.text}
126 </Text>
127 ))}
128 </Box>
129 )
130 })
131}
132hooks/line.ts 144 lines1import type { SessionUsage } from 'claude-code'
2
3import type { Segment } from '../types'
4
5// ANSI 0-15 as the app's own theme keys, so with no terminal palette to copy
6// the line still follows whatever theme the desktop app is in.
7export const fallbackPalette: string[] = [
8 'inactive', 'error', 'success', 'warning', 'permission', 'merged', 'suggestion', 'text',
9 'inactive', 'error', 'success', 'warning', 'permission', 'merged', 'suggestion', 'text',
10]
11
12/** Lays `palette = N=#rrggbb` lines from ghostty config texts, in load order, over the fallback. */
13export function parsePalette(...texts: string[]): string[] {
14 const palette = [...fallbackPalette]
15 for (const text of texts) {
16 for (const m of text.matchAll(/^\s*palette\s*=\s*(\d+)\s*=\s*(#[0-9a-fA-F]{6})/gm)) {
17 const slot = Number(m[1])
18 const hex = m[2]
19 if (hex && slot < 16) palette[slot] = hex
20 }
21 }
22 return palette
23}
24
25/** The files a ghostty config pulls in with `config-file =`, resolved against its folder. */
26export function ghosttyIncludes(text: string, dir: string): string[] {
27 const out: string[] = []
28 for (const m of text.matchAll(/^\s*config-file\s*=\s*"?\??([^"\n]+?)"?\s*$/gm)) {
29 const path = m[1]
30 if (!path) continue
31 out.push(path.startsWith('/') ? path : `${dir}/${path}`)
32 }
33 return out
34}
35
36type Style = Omit<Segment, 'text'>
37
38function applySgr(params: number[], style: Style, palette: string[]): Style {
39 let next = { ...style }
40 for (let i = 0; i < params.length; i++) {
41 const p = params[i] ?? 0
42 if (p === 0) next = {}
43 else if (p === 1) next.bold = true
44 else if (p === 2) next.dim = true
45 else if (p === 22) { delete next.bold; delete next.dim }
46 else if (p === 39) delete next.color
47 else if (p >= 30 && p <= 37) next.color = palette[p - 30]
48 else if (p >= 90 && p <= 97) next.color = palette[p - 90 + 8]
49 else if (p === 38 && params[i + 1] === 5) {
50 const n = params[i + 2]
51 if (n !== undefined && n < 16) next.color = palette[n]
52 i += 2
53 } else if (p === 38 && params[i + 1] === 2) {
54 const [r, g, b] = params.slice(i + 2, i + 5).map(v => (v ?? 0).toString(16).padStart(2, '0'))
55 next.color = `#${r}${g}${b}`
56 i += 4
57 }
58 }
59 return next
60}
61
62/** Splits a status line's ANSI output into styled runs; codes other than SGR are dropped. */
63export function parseAnsi(raw: string, palette: string[] = fallbackPalette): Segment[] {
64 const out: Segment[] = []
65 let style: Style = {}
66 const re = /\x1b\[([0-9;]*)([A-Za-z])/g
67 let last = 0
68 const push = (text: string) => {
69 if (text) out.push({ text, ...style })
70 }
71 for (const m of raw.matchAll(re)) {
72 push(raw.slice(last, m.index))
73 last = (m.index ?? 0) + m[0].length
74 if (m[2] === 'm') {
75 const params = !m[1] ? [0] : m[1].split(';').map(Number)
76 style = applySgr(params, style, palette)
77 }
78 }
79 push(raw.slice(last))
80 // The terminal shows one line; so does the band.
81 return out.map(s => ({ ...s, text: s.text.replace(/\r?\n/g, ' ') }))
82}
83
84export type InputFacts = {
85 sessionId: string
86 cwd: string
87 root: string
88 model: string
89 version: string
90 usage: SessionUsage
91}
92
93/** The JSON Claude Code pipes to a statusLine command, built from `$.session`. */
94export function buildInput(f: InputFacts): Record<string, unknown> {
95 const { context, cost, rateLimits } = f.usage
96 const limits: Record<string, { used_percentage: number; resets_at?: number }> = {}
97 for (const r of rateLimits) {
98 limits[r.kind] = {
99 used_percentage: r.percentUsed,
100 ...(r.resetsAt ? { resets_at: Math.floor(Date.parse(r.resetsAt) / 1000) } : {}),
101 }
102 }
103 return {
104 hook_event_name: 'Status',
105 session_id: f.sessionId,
106 cwd: f.cwd,
107 model: { id: f.model, display_name: f.model },
108 workspace: { current_dir: f.cwd, project_dir: f.root },
109 version: f.version,
110 ...(cost ? { cost: { total_cost_usd: cost.usd } } : {}),
111 context_window: {
112 context_window_size: context.window,
113 ...(context.tokens !== undefined
114 ? {
115 total_input_tokens: context.tokens,
116 // Only the input side is known here; output tokens read as 0.
117 current_usage: {
118 input_tokens: context.tokens,
119 output_tokens: 0,
120 cache_read_input_tokens: 0,
121 cache_creation_input_tokens: 0,
122 },
123 }
124 : {}),
125 ...(context.percent !== undefined ? { used_percentage: context.percent } : {}),
126 },
127 ...(rateLimits.length ? { rate_limits: limits } : {}),
128 }
129}
130
131/** The statusLine command from the first settings text that names one, most specific first. */
132export function pickCommand(settingsTexts: (string | null)[]): string | null {
133 for (const text of settingsTexts) {
134 if (!text) continue
135 try {
136 const line = JSON.parse(text)?.statusLine
137 if (line?.type === 'command' && typeof line.command === 'string') return line.command
138 } catch {
139 // A settings file mid-edit is not fatal; the next one may name it.
140 }
141 }
142 return null
143}
144types/index.d.ts 14 lines1/** One run of the status line's text, styled as its ANSI codes said. */
2export type Segment = {
3 text: string
4 color?: string
5 bold?: boolean
6 dim?: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'statusline-desktop': { line: Segment[] | null }
12 }
13}
14