SLOPSHOPPER

nobara-bar

Context window as a stacked, per-category bar under the prompt, themed for Nobara Linux

newspinnerguardcommandprompt
v0.1.0no licenseupdated 2026-10-03jalsarraf0/claudemod/nobara-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nobara-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-bar ⎿ nobara-bar: Context bar off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

nobara-bar

A Claude Code mod that draws your context window as a stacked bar on the bottom row of the terminal UI. Each segment is one /context category in the same color /context uses. The bar is themed for Nobara Linux.

◢ CONTEXT Nobara Linux 44 · KDE ╞█████████████▒▒▒▒▒▒░░░░░░░░╡ 58% 116k/200k ◌

On a phone (Termius portrait):

58% ▕██████████·········▏ 116k

Install

claude --plugin-dir ~/claudemod/nobara-bar

CLAUDE_CODE_PLUGIN_DIRS=~/claudemod/nobara-bar in the environment, or in the env block of ~/.claude/settings.json, loads it in sessions where you can't pass a flag (desktop app, SDK).

Command

CommandEffect
/context-barToggle the bar on/off
/context-bar mobileToggle mobile layout (toggling again returns to auto)
/context-bar desktopForce the full desktop layout
/context-bar autoChoose by width: mobile at 56 columns or fewer (the default)

Choosing a layout also turns the bar on.

What it draws

  • Desktop: ◢ CONTEXT <OS name+version · flavor> ╞bar╡ <percent> <used>/<window> ◌. The status glyph turns to ◉ while a turn is running. As the row gets narrower it drops the flavor, then the OS label, then the word CONTEXT, then the token count. Below that it falls back to the mobile row.
  • Mobile: bold percent first, then the bar inside ▕…▏. Free space and the autocompact buffer are drawn as · because iOS fonts shimmer on ░. The used-token count shows only at 40 columns or wider.
  • Segments: used categories █, then the autocompact buffer ▒, then free space ░ (dim). Deferred tool schemas are left out, as in the /context grid. Widths use largest-remainder rounding, so the cells always add up to the bar width. Any non-empty used category gets at least one cell.
  • Heat: the percent and token count are blue under 70%, yellow from 70% and coral/red from 85%.

Nobara theming

/etc/os-release is read once at session start. ID=nobara is checked before anything else: Nobara reports ID_LIKE="rhel centos fedora", so matching on the first ID_LIKE entry would pick rhel. NAME + VERSION_ID form the label, and VARIANT_ID picks the flavor tag (KDE, GNOME, HTPC, Handheld, Steam).

RoleNobaraOther distros
Accent / cool heat#7289D9#5B8DEF
Brackets#2671B2#3B6FB6
Header glyph / hot heat#F96855 (coral)#5B8DEF / theme error
Warm heattheme warningtheme warning

The brand colors come from a third-party brand listing, so treat them as approximate. On the Steam-HTPC and Steam-Handheld editions, auto mode defaults to the compact mobile row.

Caveats

  • It replaces the engine's hint line. The bar draws on the PromptHint row, so while it shows you won't see ? for shortcuts, esc to interrupt or the pills on that line. Run /context-bar to hide the bar and get them back.
  • The breakdown is the local summary estimate, the same one /context starts from. It refreshes on session start, on prompt submit, after each tool call, on turn complete, and after any /context-bar command.

Development

claude plugin validate ~/claudemod/nobara-bar
claude plugin test ~/claudemod/nobara-bar

The pure logic lives in hooks/lib/ (cell allocation, token formatting, os-release parsing, mode parsing, layout) and is covered by tests/. tests/hooks.test.ts drives the hooks through the engine's test kit.

Source 9 files
hooks/register.tsx 133 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BarMode, OsInfo, Snapshot } from '../types'
5import { layoutRow } from './lib/layout'
6import type { Run } from './lib/layout'
7import { isMobileLayout, nextMode, parseCommand } from './lib/mode'
8import { osInfoFrom } from './lib/osrelease'
9import { snapshotFrom } from './lib/snapshot'
10
11const COMMAND = 'context-bar'
12
13const isOn = atom({ plugin: 'nobara-bar', key: 'isOn' } as const, true)
14const mode = atom({ plugin: 'nobara-bar', key: 'mode' } as const, 'auto' as BarMode)
15const snapshot = atom({ plugin: 'nobara-bar', key: 'snapshot' } as const, null as Snapshot | null)
16const os = atom({ plugin: 'nobara-bar', key: 'os' } as const, null as OsInfo | null)
17
18async function refresh($: EngineInterface): Promise<void> {
19  try {
20    const usage = await $.session.usage({ breakdown: 'summary', columns: 80 })
21    const breakdown = usage.context.breakdown
22    if (breakdown === undefined) return
23    const next = snapshotFrom(breakdown)
24    await update($, snapshot, () => next)
25  } catch {
26    // No session bound yet, or the breakdown failed: keep the last bar.
27  }
28}
29
30async function detectOs($: EngineInterface): Promise<void> {
31  try {
32    const info = osInfoFrom(await $.fs.read('/etc/os-release'))
33    await update($, os, () => info)
34  } catch {
35    // Unreadable os-release: the neutral palette and no label.
36  }
37}
38
39function styleOf(run: Run): { color?: string; bold?: boolean; dimColor?: boolean } {
40  const style: { color?: string; bold?: boolean; dimColor?: boolean } = {}
41  if (run.color !== undefined) style.color = run.color
42  if (run.bold === true) style.bold = true
43  if (run.dim === true) style.dimColor = true
44  return style
45}
46
47function describeMode(value: BarMode): string {
48  if (value === 'mobile') return 'mobile layout (compact row)'
49  if (value === 'desktop') return 'desktop layout (full row)'
50  return 'auto layout (mobile at 56 columns or fewer)'
51}
52
53export const register: Register = on => {
54  on('session.start', async ($, e, next) => {
55    await $.command.register({
56      name: COMMAND,
57      description: 'Context bar: toggle, or `mobile` | `desktop` | `auto` layout',
58    })
59    await detectOs($)
60    await refresh($)
61
62    return next(e)
63  })
64
65  on('prompt.submit', async ($, e, next) => {
66    const result = await next(e)
67    await refresh($)
68
69    return result
70  })
71
72  on('tool.call', async ($, e, next) => {
73    const result = await next(e)
74    await refresh($)
75
76    return result
77  })
78
79  on('turn.complete', async ($, e, next) => {
80    const result = await next(e)
81    await refresh($)
82
83    return result
84  })
85
86  on('command.run', { command: 'context-bar' }, async ($, e) => {
87    const command = parseCommand(e.args)
88
89    if (command.kind === 'unknown') {
90      return { text: `Unknown option "${command.arg}". Use /context-bar [mobile|desktop|auto].` }
91    }
92
93    if (command.kind === 'toggle') {
94      const shown = await update($, isOn, value => !value)
95      await refresh($)
96
97      return { text: shown ? 'Context bar on.' : 'Context bar off.' }
98    }
99
100    const chosen = await update($, mode, value => nextMode(value, command))
101    await update($, isOn, () => true)
102    await refresh($)
103
104    return { text: `Context bar: ${describeMode(chosen)}.` }
105  })
106
107  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
108    if (!(await read($, isOn))) return next(e)
109    const snap = await read($, snapshot)
110    if (snap === null) return next(e)
111
112    const info = await read($, os)
113    const columns = e.viewport?.columns ?? 80
114    const isMobile = isMobileLayout(await read($, mode), columns, info?.isCompact ?? false)
115    const runs = layoutRow({
116      snapshot: snap,
117      os: info,
118      columns,
119      isMobile,
120      isWorking: e.props.isWorking,
121    })
122    const { Box, Text } = $.ui.resolve(e)
123
124    return (
125      <Box flexDirection="row" flexWrap="nowrap">
126        {runs.map(run => (
127          <Text {...styleOf(run)}>{run.text}</Text>
128        ))}
129      </Box>
130    )
131  })
132}
133
hooks/lib/layout.ts 136 lines
1// Lays the bar row out as styled runs of text, one cell per character.
2// Every glyph used here is a single column wide.
3
4import type { OsInfo, Segment, Snapshot } from '../../types'
5import { allocateCells } from './cells'
6import { formatTokens, heatOf } from './format'
7import { paletteFor } from './theme'
8
9export type Run = { text: string; color?: string; bold?: boolean; dim?: boolean }
10
11export type RowInput = {
12  snapshot: Snapshot
13  os: OsInfo | null
14  columns: number
15  isMobile: boolean
16  isWorking: boolean
17}
18
19const MIN_DESKTOP_BAR = 12
20const MIN_MOBILE_BAR = 4
21const MOBILE_TOKENS_MIN_COLUMNS = 40
22
23const RANK: Record<Segment['kind'], number> = { used: 0, buffer: 1, free: 2 }
24
25export function orderSegments(segments: readonly Segment[]): Segment[] {
26  return segments
27    .map((segment, i) => ({ segment, i }))
28    .sort((a, b) => RANK[a.segment.kind] - RANK[b.segment.kind] || a.i - b.i)
29    .map(entry => entry.segment)
30}
31
32export function barRuns(segments: readonly Segment[], width: number, isMobile: boolean): Run[] {
33  const ordered = orderSegments(segments)
34  const cells = allocateCells(
35    ordered.map(s => ({ weight: s.tokens, isRequired: s.kind === 'used' })),
36    width,
37  )
38  const runs: Run[] = []
39  ordered.forEach((segment, i) => {
40    const n = cells[i] ?? 0
41    if (n <= 0) return
42    if (segment.kind === 'used') {
43      runs.push({ text: '█'.repeat(n), color: segment.color })
44    } else if (segment.kind === 'buffer') {
45      runs.push({ text: (isMobile ? '·' : '▒').repeat(n), color: segment.color })
46    } else {
47      runs.push({ text: (isMobile ? '·' : '░').repeat(n), color: segment.color, dim: true })
48    }
49  })
50  return runs
51}
52
53export function runsWidth(runs: readonly Run[]): number {
54  return runs.reduce((sum, run) => sum + [...run.text].length, 0)
55}
56
57function percentText(percent: number): string {
58  return `${Math.max(0, Math.round(percent))}%`
59}
60
61export function mobileRow(input: RowInput): Run[] {
62  const { snapshot, os, columns } = input
63  const palette = paletteFor(os?.isNobara ?? false)
64  const heat = palette.heat[heatOf(snapshot.percent)]
65  const avail = Math.max(0, columns - 1)
66  const pct = percentText(snapshot.percent)
67  const tokens = columns >= MOBILE_TOKENS_MIN_COLUMNS ? formatTokens(snapshot.usedTokens) : null
68  const fixed = pct.length + 1 + 2 + (tokens === null ? 0 : tokens.length + 1)
69  const width = Math.max(MIN_MOBILE_BAR, avail - fixed)
70
71  const runs: Run[] = [
72    { text: pct, color: heat, bold: true },
73    { text: ' ' },
74    { text: '▕', color: palette.deep },
75    ...barRuns(snapshot.segments, width, true),
76    { text: '▏', color: palette.deep },
77  ]
78  if (tokens !== null) runs.push({ text: ' ' }, { text: tokens, color: heat })
79  return runs
80}
81
82type Head = { word: boolean; label: string | null }
83
84export function desktopRow(input: RowInput): Run[] | null {
85  const { snapshot, os, columns, isWorking } = input
86  const palette = paletteFor(os?.isNobara ?? false)
87  const heat = palette.heat[heatOf(snapshot.percent)]
88  const avail = Math.max(0, columns - 1)
89  const pct = percentText(snapshot.percent)
90  const tokens = `${formatTokens(snapshot.usedTokens)}/${formatTokens(snapshot.maxTokens)}`
91
92  const full = os === null ? null : os.flavor === null ? os.label : `${os.label} · ${os.flavor}`
93  const heads: Head[] = [
94    { word: true, label: full },
95    { word: true, label: os?.label ?? null },
96    { word: true, label: null },
97    { word: false, label: null },
98  ]
99
100  for (const withTokens of [true, false]) {
101    for (const head of heads) {
102      const headWidth = 1 + (head.word ? 8 : 0) + (head.label === null ? 0 : head.label.length + 1) + 1
103      const tailWidth = 1 + pct.length + (withTokens ? tokens.length + 1 : 0) + 2
104      const width = avail - headWidth - 2 - tailWidth
105      if (width < MIN_DESKTOP_BAR) continue
106
107      const runs: Run[] = [{ text: '◢', color: palette.glyph, bold: true }]
108      if (head.word) runs.push({ text: ' CONTEXT', color: palette.accent, bold: true })
109      if (head.label !== null) runs.push({ text: ` ${head.label}`, dim: true })
110      runs.push(
111        { text: ' ' },
112        { text: '╞', color: palette.deep },
113        ...barRuns(snapshot.segments, width, false),
114        { text: '╡', color: palette.deep },
115        { text: ' ' },
116        { text: pct, color: heat, bold: true },
117      )
118      if (withTokens) runs.push({ text: ' ' }, { text: tokens, color: heat })
119      runs.push(
120        { text: ' ' },
121        isWorking ? { text: '◉', color: palette.accent, bold: true } : { text: '◌', dim: true },
122      )
123      return runs
124    }
125  }
126  return null
127}
128
129export function layoutRow(input: RowInput): Run[] {
130  if (!input.isMobile) {
131    const desktop = desktopRow(input)
132    if (desktop !== null) return desktop
133  }
134  return mobileRow(input)
135}
136
hooks/lib/mode.ts 33 lines
1// /context-bar arguments and layout choice.
2
3import type { BarMode } from '../../types'
4
5export const MOBILE_MAX_COLUMNS = 56
6
7export type BarCommand =
8  | { kind: 'toggle' }
9  | { kind: 'mode'; mode: BarMode }
10  | { kind: 'toggle-mobile' }
11  | { kind: 'unknown'; arg: string }
12
13export function parseCommand(args: string): BarCommand {
14  const arg = args.trim().toLowerCase()
15  if (arg === '') return { kind: 'toggle' }
16  if (arg === 'mobile') return { kind: 'toggle-mobile' }
17  if (arg === 'desktop' || arg === 'auto') return { kind: 'mode', mode: arg }
18  return { kind: 'unknown', arg: args.trim() }
19}
20
21export function nextMode(current: BarMode, command: BarCommand): BarMode {
22  if (command.kind === 'toggle-mobile') return current === 'mobile' ? 'auto' : 'mobile'
23  if (command.kind === 'mode') return command.mode
24  return current
25}
26
27// Steam HTPC/Handheld editions default to the compact row under auto.
28export function isMobileLayout(mode: BarMode, columns: number, isCompactOs: boolean): boolean {
29  if (mode === 'mobile') return true
30  if (mode === 'desktop') return false
31  return columns <= MOBILE_MAX_COLUMNS || isCompactOs
32}
33
hooks/lib/osrelease.ts 74 lines
1// /etc/os-release parsing and distro detection.
2
3import type { OsInfo } from '../../types'
4
5export function parseOsRelease(text: string): Record<string, string> {
6  const fields: Record<string, string> = {}
7  for (const raw of text.split('\n')) {
8    const line = raw.trim()
9    if (line === '' || line.startsWith('#')) continue
10    const eq = line.indexOf('=')
11    if (eq <= 0) continue
12    const key = line.slice(0, eq).trim()
13    let value = line.slice(eq + 1).trim()
14    const quote = value[0]
15    if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length >= 2) {
16      value = value.slice(1, -1)
17      if (quote === '"') value = value.replace(/\\(["\\$`])/g, '$1')
18    }
19    fields[key] = value
20  }
21  return fields
22}
23
24// Distros the bar knows by name, used only when ID is not nobara.
25const KNOWN: Record<string, string> = {
26  fedora: 'Fedora',
27  rhel: 'RHEL',
28  centos: 'CentOS',
29  debian: 'Debian',
30  ubuntu: 'Ubuntu',
31  arch: 'Arch',
32  opensuse: 'openSUSE',
33}
34
35// Nobara reports ID_LIKE="rhel centos fedora", so a first-ID_LIKE-match
36// lookup would pick rhel. ID wins, and ID=nobara is checked before anything.
37export function detectDistro(fields: Record<string, string>): string {
38  const id = (fields.ID ?? '').toLowerCase()
39  if (id === 'nobara') return 'nobara'
40  if (id !== '' && id in KNOWN) return id
41  for (const like of (fields.ID_LIKE ?? '').toLowerCase().split(/\s+/)) {
42    if (like in KNOWN) return like
43  }
44  return id === '' ? 'linux' : id
45}
46
47export function flavorOf(fields: Record<string, string>): string | null {
48  const variant = `${fields.VARIANT_ID ?? ''} ${fields.VARIANT ?? ''}`.toLowerCase()
49  if (/htpc/.test(variant)) return 'HTPC'
50  if (/handheld|deck/.test(variant)) return 'Handheld'
51  if (/steam/.test(variant)) return 'Steam'
52  if (/gnome/.test(variant)) return 'GNOME'
53  if (/kde|plasma/.test(variant)) return 'KDE'
54  if (/official/.test(variant)) return 'Official'
55  const id = (fields.VARIANT_ID ?? '').trim()
56  return id === '' ? null : id.toUpperCase()
57}
58
59export function osInfoFrom(text: string): OsInfo {
60  const fields = parseOsRelease(text)
61  const id = detectDistro(fields)
62  const isNobara = id === 'nobara'
63  const name = fields.NAME ?? KNOWN[id] ?? 'Linux'
64  const version = fields.VERSION_ID ?? ''
65  const flavor = flavorOf(fields)
66  return {
67    id,
68    isNobara,
69    label: version === '' ? name : `${name} ${version}`,
70    flavor,
71    isCompact: isNobara && (flavor === 'HTPC' || flavor === 'Handheld' || flavor === 'Steam'),
72  }
73}
74
hooks/lib/snapshot.ts 26 lines
1// Turns /context's breakdown into what the bar draws.
2
3import type { SessionContextBreakdown } from 'claude-code'
4
5import type { Segment, Snapshot } from '../../types'
6
7export function snapshotFrom(breakdown: SessionContextBreakdown): Snapshot {
8  const segments: Segment[] = []
9  for (const category of breakdown.categories) {
10    if (category.kind === 'deferred' || category.isDeferred) continue
11    if (category.tokens <= 0 && category.kind !== 'free') continue
12    segments.push({
13      name: category.name,
14      tokens: Math.max(0, category.tokens),
15      color: category.color,
16      kind: category.kind,
17    })
18  }
19  return {
20    segments,
21    usedTokens: breakdown.totalTokens,
22    maxTokens: breakdown.rawMaxTokens,
23    percent: breakdown.percentage,
24  }
25}
26
hooks/lib/cells.ts 70 lines
1// Largest-remainder allocation of `width` cells across weighted segments.
2// Cells always sum to `width`; a segment marked `isRequired` with weight
3// above zero gets at least one cell whenever the width allows it.
4
5export type Weighted = { weight: number; isRequired: boolean }
6
7export function allocateCells(items: readonly Weighted[], width: number): number[] {
8  const cells = items.map(() => 0)
9  if (width <= 0 || items.length === 0) return cells
10
11  const total = items.reduce((sum, item) => sum + Math.max(0, item.weight), 0)
12  if (total <= 0) {
13    cells[cells.length - 1] = width
14    return cells
15  }
16
17  const quotas = items.map(item => (Math.max(0, item.weight) * width) / total)
18  const floors = quotas.map(Math.floor)
19  const minimum = items.map(item => (item.isRequired && item.weight > 0 ? 1 : 0))
20  floors.forEach((n, i) => (cells[i] = Math.max(n, minimum[i] ?? 0)))
21
22  let diff = width - cells.reduce((sum, n) => sum + n, 0)
23
24  // Hand out the leftover cells by largest fractional remainder.
25  if (diff > 0) {
26    const order = quotas
27      .map((q, i) => ({ i, remainder: q - (floors[i] ?? 0), bumped: (cells[i] ?? 0) > (floors[i] ?? 0) }))
28      .filter(entry => !entry.bumped && (items[entry.i]?.weight ?? 0) > 0)
29      .sort((a, b) => b.remainder - a.remainder || a.i - b.i)
30    for (let k = 0; diff > 0 && order.length > 0; k = (k + 1) % order.length) {
31      const entry = order[k]
32      if (entry === undefined) break
33      cells[entry.i] = (cells[entry.i] ?? 0) + 1
34      diff -= 1
35    }
36  }
37
38  // Minimums can overshoot: take cells back where it costs the least.
39  while (diff < 0) {
40    let pick = -1
41    let best = -Infinity
42    cells.forEach((n, i) => {
43      if (n <= (minimum[i] ?? 0)) return
44      const excess = n - (quotas[i] ?? 0)
45      if (excess > best || (excess === best && n > (cells[pick] ?? 0))) {
46        best = excess
47        pick = i
48      }
49    })
50    if (pick < 0) break
51    cells[pick] = (cells[pick] ?? 0) - 1
52    diff += 1
53  }
54
55  // Too narrow for every minimum: keep the heaviest required segments.
56  if (diff < 0) {
57    const ranked = items
58      .map((item, i) => ({ i, weight: item.weight }))
59      .filter(entry => (cells[entry.i] ?? 0) > 0)
60      .sort((a, b) => a.weight - b.weight || b.i - a.i)
61    for (const entry of ranked) {
62      if (diff >= 0) break
63      cells[entry.i] = 0
64      diff += 1
65    }
66  }
67
68  return cells
69}
70
hooks/lib/format.ts 23 lines
1// Compact token counts: 950, 9.5k, 116k, 1M, 1.2M.
2
3function trim(n: number): string {
4  return n.toFixed(1).replace(/\.0$/, '')
5}
6
7export function formatTokens(tokens: number): string {
8  const n = Math.max(0, Math.round(tokens))
9  if (n < 1000) return String(n)
10  if (n < 10_000) return `${trim(n / 1000)}k`
11  if (n < 999_500) return `${Math.round(n / 1000)}k`
12  if (n < 10_000_000) return `${trim(n / 1_000_000)}M`
13  return `${Math.round(n / 1_000_000)}M`
14}
15
16export type Heat = 'cool' | 'warm' | 'hot'
17
18export function heatOf(percent: number): Heat {
19  if (percent >= 85) return 'hot'
20  if (percent >= 70) return 'warm'
21  return 'cool'
22}
23
hooks/lib/theme.ts 31 lines
1// Colours: Nobara's brand palette (from a third-party brand listing, so
2// approximate), and a neutral blue for every other distro. Values are raw
3// colours or theme keys; the terminal takes either.
4
5import type { Heat } from './format'
6
7export type Palette = {
8  accent: string
9  deep: string
10  glyph: string
11  heat: Record<Heat, string>
12}
13
14export const NOBARA: Palette = {
15  accent: '#7289D9',
16  deep: '#2671B2',
17  glyph: '#F96855',
18  heat: { cool: '#7289D9', warm: 'warning', hot: '#F96855' },
19}
20
21export const NEUTRAL: Palette = {
22  accent: '#5B8DEF',
23  deep: '#3B6FB6',
24  glyph: '#5B8DEF',
25  heat: { cool: '#5B8DEF', warm: 'warning', hot: 'error' },
26}
27
28export function paletteFor(isNobara: boolean): Palette {
29  return isNobara ? NOBARA : NEUTRAL
30}
31
types/index.d.ts 37 lines
1export type BarMode = 'auto' | 'mobile' | 'desktop'
2
3export type SegmentKind = 'used' | 'free' | 'buffer'
4
5export type Segment = {
6  name: string
7  tokens: number
8  color: string
9  kind: SegmentKind
10}
11
12export type Snapshot = {
13  segments: Segment[]
14  usedTokens: number
15  maxTokens: number
16  percent: number
17}
18
19export type OsInfo = {
20  id: string
21  isNobara: boolean
22  label: string
23  flavor: string | null
24  isCompact: boolean
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'nobara-bar': {
30      isOn: boolean
31      mode: BarMode
32      snapshot: Snapshot | null
33      os: OsInfo | null
34    }
35  }
36}
37