SLOPSHOPPER

statusline-desktop

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

newbandguardprocesstimer
★ 1v0.1.0MITupdated 2026-10-09enhki/claude-mods/mods/statusline-desktop
A shopper browsing a rack in a slop shop
README

claude-mods

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.

Install

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.

Mods

ModWhat it does
statusline-desktopDraws your terminal status line above the prompt in the desktop app

statusline-desktop

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.

  • ANSI colours keep the terminal's palette when ghostty's config (and the files it pulls in with config-file) sets one; otherwise they map to the app's own theme colours.
  • Refreshes on session start, after each tool call and turn, and every 30 s.
  • Stays out of the terminal, which already draws the real status line.
  • Only the input side of the context window is known to mods, so a token count your script sums from current_usage reads slightly lower than in the terminal (output tokens come through as 0).

Developing

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>
Source 3 files
hooks/register.tsx 132 lines
1import { 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}
132
hooks/line.ts 144 lines
1import 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}
144
types/index.d.ts 14 lines
1/** 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