SLOPSHOPPER

statusline-anywhere

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…

newbandprocesstimer
★ 3v0.1.0MITupdated 2026-10-02NathanAB/statusline-anywhere
A shopper browsing a rack in a slop shop
README

statusline-anywhere

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

The Catppuccin Frappé status line drawn above the prompt in Claude Desktop

<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.

Install

  1. Paste this into a terminal:
   claude plugin marketplace add NathanAB/statusline-anywhere && claude plugin install statusline-anywhere@statusline-anywhere
  1. Open a new Code session in Claude Desktop. Your status line is above the prompt.

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

How it works

  • It runs your statusLine command with the same kind of JSON Claude Code sends, so the script you already use works as it is.
  • It only draws outside the terminal. In a terminal, Claude Code already shows your status line, so the plugin stays out of the way.
  • It refreshes when a session starts, after each reply, and every 60 seconds, or every refreshInterval seconds if you set one.

What your script receives

Claude Code sends status line scripts a JSON object on stdin. statusline-anywhere builds the same object from what a plugin can see:

  • session_id
  • transcript_path
  • cwd
  • model (id, display_name)
  • workspace (current_dir, project_dir)
  • version
  • cost (total_cost_usd, total_duration_ms)
  • context_window (total_input_tokens, context_window_size, used_percentage, remaining_percentage)
  • exceeds_200k_tokens
  • rate_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.

What it does on your machine

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.

Limits

  • Not on Windows yet. It runs your command through sh.
  • Links aren't clickable. Links in your status line show as plain text.
  • Another plugin can take the spot. If a second plugin also draws above the prompt, only one of them shows.

Develop

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.

License

MIT. Made by statuslin.es, the gallery of Claude Code status lines.

Source 3 files
hooks/register.ts 138 lines
1// 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}
138
hooks/ansi.ts 130 lines
1// 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}
130
hooks/payload.ts 75 lines
1// 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