Shows your Claude Code status line in the Claude Desktop app. Runs the statusLine command you already have and draws its output above the prompt. Changes…

Your Claude Code status line, in the desktop app too.

<sub>Catppuccin Frappé from statuslin.es, running in Claude Desktop with statusline-anywhere.</sub>
Claude Desktop doesn't draw custom status lines (#41456). This plugin does. It runs the statusLine command you already have and shows the output above the prompt, in color.
claude plugin marketplace add NathanAB/statusline-anywhere && claude plugin install statusline-anywhere@statusline-anywhere
That's it. You need Claude Code 2.1.286 or later and a status line. No status line yet? Pick one from statuslin.es.
/plugin marketplace add NathanAB/statusline-anywhere
/plugin install statusline-anywhere@statusline-anywhere
/reload-plugins
To remove it: claude plugin uninstall statusline-anywhere@statusline-anywhere
statusLine command with the same kind of JSON Claude Code sends, so the script you already use works as it is.refreshInterval seconds if you set one.Claude Code sends status line scripts a JSON object on stdin. statusline-anywhere builds the same object from what a plugin can see:
session_idtranscript_pathcwdmodel (id, display_name)workspace (current_dir, project_dir)versioncost (total_cost_usd, total_duration_ms)context_window (total_input_tokens, context_window_size, used_percentage, remaining_percentage)exceeds_200k_tokensrate_limits (five_hour, seven_day, spend_limit)A plugin can't see everything Claude Code can. These fields are missing: lines changed, PR, vim mode, prompt cache, effort and output style. If your status line shows one of them, that part stays blank in Desktop. Claude Code also leaves out fields it has no value for, so scripts that follow its docs already handle a missing field.
It runs your status line command as you, which is what Claude Code does with it in a terminal. Beyond that, it only reads your settings and session info, and draws. The plugin itself makes no network requests and writes no files.
claude plugin validate lists its full footprint: $.settings.read, $.session.* reads, $.env.get for HOME and CLAUDE_CONFIG_DIR, $.process.run, $.clock, and drawing.
sh.statusline-anywhere is a Claude Code mod. Use Claude Code 2.1.286 or later.
claude plugin validate .
claude plugin test
To try a change in a session, run claude --plugin-dir ..
To type-check, load the plugin once with --plugin-dir so Claude Code writes its types to .claude-plugin/types/. Then run npx tsc -p .claude-plugin/types/tsconfig.json.
MIT. Made by statuslin.es, the gallery of Claude Code status lines.
hooks/register.ts 138 lines1// statusline-anywhere: shows your Claude Code status line where Claude Code doesn't draw it, such as
2// the Claude Desktop app. It runs the `statusLine` command from your settings with the same kind of
3// JSON input Claude Code sends, and draws the output in the band above the prompt.
4//
5// In a terminal, Claude Code already draws the status line, so this mod neither runs the command
6// nor draws there.
7
8import type { EngineInterface, On, RenderSurface, Settings } from 'claude-code'
9import { parseAnsiLine, type Segment } from './ansi'
10import { buildStatusInput } from './payload'
11
12// Claude Code reruns a status line on events this mod can't see, so a timer stands in for them
13const DEFAULT_REFRESH_MS = 60_000
14const RUN_TIMEOUT_MS = 10_000
15const PREFIX = 'statusline-anywhere: '
16
17type StatusLine = { command: string; padding: number; refreshMs: number }
18
19let lines: Segment[][] = []
20let failure: string | null = null
21let padding = 0
22let running = false
23
24function readStatusLine(settings: Settings): StatusLine | null {
25 const value = settings.statusLine
26 if (typeof value !== 'object' || value === null) return null
27 const { command, padding, refreshInterval } = value as Record<string, unknown>
28 if (typeof command !== 'string' || command.trim() === '') return null
29 return {
30 command,
31 padding: typeof padding === 'number' && padding > 0 ? padding : 0,
32 refreshMs:
33 typeof refreshInterval === 'number' && refreshInterval >= 1
34 ? refreshInterval * 1000
35 : DEFAULT_REFRESH_MS,
36 }
37}
38
39// Empty means no app has attached yet. Desktop attaches after session start, so only a session
40// that is known to be terminal-only is skipped.
41function isTerminalOnly(surfaces: readonly RenderSurface[]): boolean {
42 return surfaces.length > 0 && surfaces.every((surface) => surface === 'terminal')
43}
44
45function describeError(error: unknown): string {
46 return error instanceof Error ? error.message : String(error)
47}
48
49async function configDir($: EngineInterface): Promise<string> {
50 const custom = await $.env.get('CLAUDE_CONFIG_DIR')
51 if (custom) return custom
52 return `${(await $.env.get('HOME')) ?? ''}/.claude`
53}
54
55async function runStatusLine($: EngineInterface, statusLine: StatusLine) {
56 const cwd = await $.session.cwd()
57 const input = buildStatusInput({
58 sessionId: await $.session.id(),
59 root: await $.session.root(),
60 cwd,
61 model: await $.session.model(),
62 version: (await $.session.version()).version,
63 configDir: await configDir($),
64 now: await $.clock.now(),
65 usage: await $.session.usage(),
66 })
67 return $.process.run(['sh', '-c', statusLine.command], {
68 cwd,
69 stdin: JSON.stringify(input),
70 timeoutMs: RUN_TIMEOUT_MS,
71 })
72}
73
74async function refresh($: EngineInterface) {
75 if (running || isTerminalOnly(await $.session.surfaces())) return
76 const statusLine = readStatusLine(await $.settings.read())
77 running = true
78 try {
79 if (statusLine === null) {
80 lines = []
81 failure = null
82 return
83 }
84 padding = statusLine.padding
85 const result = await runStatusLine($, statusLine)
86 const output = result.stdout.split('\n').filter((line) => line.trim() !== '')
87 if (output.length === 0 && result.exitCode !== 0) {
88 const stderr = result.stderr.trim()
89 failure = `${PREFIX}exit ${result.exitCode}${stderr ? `: ${stderr}` : ''}`
90 lines = []
91 } else {
92 failure = null
93 lines = output.map(parseAnsiLine)
94 }
95 } catch (error) {
96 failure = PREFIX + describeError(error)
97 lines = []
98 } finally {
99 running = false
100 $.ui.invalidate('ui.render')
101 }
102}
103
104export function register(on: On) {
105 on('session.start', async ($, e, next) => {
106 const result = await next(e)
107 const statusLine = readStatusLine(await $.settings.read())
108 if (statusLine !== null) {
109 await refresh($)
110 $.clock.every(statusLine.refreshMs, () => refresh($))
111 }
112 return result
113 })
114
115 on('turn.complete', async ($, e, next) => {
116 const result = await next(e)
117 await refresh($)
118 return result
119 })
120
121 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
122 if (e.surface === 'terminal' || e.props.hasSurvey) return next(e)
123 if (failure === null && lines.length === 0) return next(e)
124 const { Box, Text } = $.ui.resolve(e)
125 const rows =
126 failure !== null
127 ? [Text({ dimColor: true, children: [failure] })]
128 : lines.map((segments, index) =>
129 Box({
130 key: `line-${index}`,
131 flexDirection: 'row',
132 children: segments.map(({ text, ...style }) => Text({ ...style, children: [text] })),
133 }),
134 )
135 return Box({ flexDirection: 'column', paddingLeft: padding, children: rows })
136 })
137}
138hooks/ansi.ts 130 lines1// Turns one line of status line output (text with ANSI escape codes) into styled segments that map
2// onto a mod's `Text` props.
3//
4// Colours: the 8 basic colours keep their names, which Desktop paints. Every other colour becomes a
5// hex string, because Desktop ignores the `ansi256(...)` form.
6
7export type Segment = {
8 text: string
9 color?: string
10 backgroundColor?: string
11 bold?: true
12 dimColor?: true
13 italic?: true
14 underline?: true
15 inverse?: true
16 strikethrough?: true
17}
18
19type Style = Omit<Segment, 'text'>
20
21const BASIC = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
22// xterm's default values for the 8 bright colours (indexes 8-15)
23const BRIGHT = [
24 '#7f7f7f',
25 '#ff0000',
26 '#00ff00',
27 '#ffff00',
28 '#5c5cff',
29 '#ff00ff',
30 '#00ffff',
31 '#ffffff',
32]
33const CUBE_LEVELS = [0, 95, 135, 175, 215, 255]
34
35// OSC (ESC ] ... BEL or ESC \), CSI (ESC [ params final), or any other two-byte escape
36// biome-ignore lint/suspicious/noControlCharactersInRegex: matching escape sequences is this regex's job
37const ESCAPE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[([0-9;:]*)([@-~])|\x1b[@-_]/g
38
39function hex(r: number, g: number, b: number): string {
40 return `#${[r, g, b].map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
41}
42
43function colourAt(index: number): string | undefined {
44 if (index < 0 || index > 255) return undefined
45 if (index < 8) return BASIC[index]
46 if (index < 16) return BRIGHT[index - 8]
47 if (index < 232) {
48 const n = index - 16
49 const level = (step: number) => CUBE_LEVELS[step] ?? 0
50 return hex(level(Math.floor(n / 36)), level(Math.floor(n / 6) % 6), level(n % 6))
51 }
52 const grey = 8 + (index - 232) * 10
53 return hex(grey, grey, grey)
54}
55
56// Reads an extended colour (38/48 followed by 5;n or 2;r;g;b) starting at codes[i]. Returns the colour
57// and how many codes it used.
58function extendedColour(codes: number[], i: number): [string | undefined, number] {
59 if (codes[i + 1] === 5) return [colourAt(codes[i + 2] ?? -1), 3]
60 if (codes[i + 1] === 2) return [hex(codes[i + 2] ?? 0, codes[i + 3] ?? 0, codes[i + 4] ?? 0), 5]
61 return [undefined, 1]
62}
63
64function applySgr(style: Style, params: string): Style {
65 const codes = params === '' ? [0] : params.split(/[;:]/).map((p) => (p === '' ? 0 : Number(p)))
66 let next: Style = { ...style }
67 for (let i = 0; i < codes.length; i++) {
68 const code = codes[i] ?? 0
69 if (code === 0) next = {}
70 else if (code === 1) next.bold = true
71 else if (code === 2) next.dimColor = true
72 else if (code === 3) next.italic = true
73 else if (code === 4) next.underline = true
74 else if (code === 7) next.inverse = true
75 else if (code === 9) next.strikethrough = true
76 else if (code === 22) {
77 delete next.bold
78 delete next.dimColor
79 } else if (code === 23) delete next.italic
80 else if (code === 24) delete next.underline
81 else if (code === 27) delete next.inverse
82 else if (code === 29) delete next.strikethrough
83 else if (code >= 30 && code <= 37) next.color = BASIC[code - 30]
84 else if (code >= 90 && code <= 97) next.color = BRIGHT[code - 90]
85 else if (code === 39) delete next.color
86 else if (code >= 40 && code <= 47) next.backgroundColor = BASIC[code - 40]
87 else if (code >= 100 && code <= 107) next.backgroundColor = BRIGHT[code - 100]
88 else if (code === 49) delete next.backgroundColor
89 else if (code === 38 || code === 48) {
90 const [colour, used] = extendedColour(codes, i)
91 if (colour !== undefined) {
92 if (code === 38) next.color = colour
93 else next.backgroundColor = colour
94 }
95 i += used - 1
96 }
97 }
98 return next
99}
100
101function sameStyle(a: Style, b: Style): boolean {
102 // Segments carry their text alongside the style; compare only the style
103 const keys = new Set(
104 [...Object.keys(a), ...Object.keys(b)].filter((key) => key !== 'text'),
105 ) as Set<keyof Style>
106 for (const key of keys) if (a[key] !== b[key]) return false
107 return true
108}
109
110export function parseAnsiLine(line: string): Segment[] {
111 const segments: Segment[] = []
112 let style: Style = {}
113 let last = 0
114
115 const push = (text: string) => {
116 if (text === '') return
117 const previous = segments[segments.length - 1]
118 if (previous && sameStyle(previous, style)) previous.text += text
119 else segments.push({ text, ...style })
120 }
121
122 for (const match of line.matchAll(ESCAPE)) {
123 push(line.slice(last, match.index))
124 if (match[2] === 'm') style = applySgr(style, match[1] ?? '')
125 last = (match.index ?? 0) + match[0].length
126 }
127 push(line.slice(last))
128 return segments
129}
130hooks/payload.ts 75 lines1// Builds the JSON a status line script reads on stdin, from what the mods API can see. Claude Code
2// documents the full shape at https://code.claude.com/docs/en/statusline#available-data. Fields
3// the mods API has no source for (lines changed, PR, vim mode, prompt cache, ...) are left out, as
4// Claude Code itself does when it has no value.
5
6import type { SessionUsage } from 'claude-code'
7
8export type SessionFacts = {
9 sessionId: string
10 root: string
11 cwd: string
12 model: string
13 version: string
14 configDir: string
15 now: number
16 usage: SessionUsage
17}
18
19// claude-<family>-<major>-<minor>, optionally with a date stamp and a [1m]-style suffix
20const MODEL_ID = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(?:\[[^\]]*\])?$/
21
22export function modelDisplayName(id: string): string {
23 const match = MODEL_ID.exec(id)
24 if (!match) return id
25 const [, family = '', major, minor] = match
26 return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}.${minor}`
27}
28
29// Claude Code keeps each session's transcript under the config dir, in a folder named after the
30// project root with every non-alphanumeric character replaced by a dash.
31export function transcriptPath(where: { configDir: string; root: string; sessionId: string }) {
32 return `${where.configDir}/projects/${where.root.replace(/[^A-Za-z0-9]/g, '-')}/${where.sessionId}.jsonl`
33}
34
35function epochSeconds(iso: string | undefined): number | undefined {
36 const ms = Date.parse(iso ?? '')
37 return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined
38}
39
40function rateLimits(limits: SessionUsage['rateLimits']) {
41 return Object.fromEntries(
42 limits.map((limit) => {
43 const resetsAt = epochSeconds(limit.resetsAt)
44 const window =
45 resetsAt === undefined
46 ? { used_percentage: limit.percentUsed }
47 : { used_percentage: limit.percentUsed, resets_at: resetsAt }
48 return [limit.kind, window]
49 }),
50 )
51}
52
53export function buildStatusInput(facts: SessionFacts) {
54 const { context, cost, startedAt } = facts.usage
55 const tokens = context.tokens ?? 0
56 const percent = context.percent ?? null
57 return {
58 session_id: facts.sessionId,
59 transcript_path: transcriptPath(facts),
60 cwd: facts.cwd,
61 model: { id: facts.model, display_name: modelDisplayName(facts.model) },
62 workspace: { current_dir: facts.cwd, project_dir: facts.root, added_dirs: [] },
63 version: facts.version,
64 cost: { total_cost_usd: cost?.usd ?? 0, total_duration_ms: facts.now - startedAt },
65 context_window: {
66 total_input_tokens: tokens,
67 context_window_size: context.window,
68 used_percentage: percent,
69 remaining_percentage: percent === null ? null : 100 - percent,
70 },
71 exceeds_200k_tokens: tokens > 200_000,
72 rate_limits: rateLimits(facts.usage.rateLimits),
73 }
74}
75