SLOPSHOPPER

context-bar

Stacked context-window bar above the prompt, one color per /context category, toggled with /context-bar; themed to the host OS.

newspinnerguardcommandprompt
v0.1.0no licenseupdated 2026-10-12jalsarraf0/claudemod/context-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-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 ⎿ context-bar: Context bar hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claudemod

Claude Code enhancement suite. Cyberdeck is the operations HUD and Codex/Grok task router; the context bars are optional focused displays. Each component is a plugin: load it with claude --plugin-dir <dir>.

ModWhat it does
context-barStacked context-window bar on the bottom row (the hint line under the prompt), one color per /context category, themed to the host OS (Fedora blue on Fedora). Built on amarillo.
nobara-barThe Nobara port: same idea, Nobara-themed, with a mobile layout for Termius. Built on dominus-nobara.
cyberdeckModel/provider, context, native and routed agents, configured MCP enablement, Git/worktree state, responsive status, and Codex/Grok smart routing. See installation, commands and limits.

context-bar and nobara-bar both register /context-bar, so load only one of them in a session.

context-bar

Load with claude --plugin-dir ./context-bar.

Mobile mode (iOS / Termius)

/context-bar mobile switches to a compact one-row layout for phone-width terminals (short label, stacked bar, percent). By default the bar is in auto and goes mobile at 56 columns or fewer, so Termius portrait needs no setup. /context-bar desktop forces the full layout, /context-bar auto restores width detection, and bare /context-bar toggles the bar on and off.

nobara-bar

See nobara-bar/README.md. Its full build spec is prompts/nobara-bar.md.

Checks

Run npm ci, npm run validate, npm run typecheck, and npm test. GitHub Actions runs the same checks for every pull request. See Cyberdeck development notes for the distinction between pure-module TypeScript checks and native hook validation.

Source 4 files
hooks/register.tsx 134 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Segment, Snapshot } from '../types'
5import { cellsFor, compact, isMobile, nextMode, shortName } from './layout'
6import { DEFAULT_THEME, themeFromOsRelease } from './theme'
7
8const ref = { plugin: 'context-bar' } as const
9const snapshot = atom({ ...ref, key: 'snapshot' } as const, null)
10const isOn = atom({ ...ref, key: 'isOn' } as const, true)
11const mode = atom({ ...ref, key: 'mode' } as const, 'auto')
12const theme = atom({ ...ref, key: 'theme' } as const, DEFAULT_THEME)
13
14const GLYPH = { used: '█', buffer: '▒', free: '░', deferred: ' ' } as const
15// iOS fonts shimmer on ░ and lack some Unicode; mobile uses plain solid glyphs.
16const MOBILE_GLYPH = { used: '█', buffer: '▒', free: '·', deferred: ' ' } as const
17
18async function refresh($: any) {
19  try {
20    const { context } = await $.session.usage({ breakdown: 'summary', columns: 80 })
21    const b = context.breakdown
22    if (!b) return
23    const segments: Segment[] = b.categories
24      .filter(c => c.kind !== 'deferred' && (c.tokens > 0 || c.kind === 'free'))
25      .map(c => ({ name: c.name, tokens: c.tokens, color: c.color, kind: c.kind }))
26    const snap: Snapshot = {
27      segments,
28      max: b.rawMaxTokens,
29      total: b.totalTokens,
30      percent: b.percentage,
31      model: b.model,
32    }
33    await update($, snapshot, () => snap)
34  } catch {
35    // no session bound yet; the band stays quiet
36  }
37}
38
39export const register: Register = on => {
40  on('session.start', async ($, e, next) => {
41    await $.command.register({
42      name: 'context-bar',
43      description: 'Toggle the stacked context-window bar above the prompt ; args: mobile | desktop | auto',
44    })
45    try {
46      await update($, theme, () => themeFromOsRelease(''))
47      const text = await $.fs.read('/etc/os-release')
48      await update($, theme, () => themeFromOsRelease(text))
49    } catch {
50      // keep the default blue
51    }
52    await refresh($)
53
54    return next(e)
55  })
56
57  on('command.run', { command: 'context-bar' }, async ($, e) => {
58    const next = nextMode(e.args, await read($, mode))
59    if (next) {
60      await update($, mode, () => next)
61      await update($, isOn, () => true)
62      await refresh($)
63
64      return { text: `Context bar: ${next === 'mobile' ? 'mobile mode on' : next === 'auto' ? 'auto layout (mobile below 57 columns)' : 'desktop layout'}.` }
65    }
66    const now = await read($, isOn)
67    await update($, isOn, () => !now)
68    await refresh($)
69
70    return { text: now ? 'Context bar hidden.' : 'Context bar shown.' }
71  })
72
73  on('prompt.submit', async ($, e, next) => {
74    const result = await next(e)
75    await refresh($)
76
77    return result
78  })
79
80  on('turn.complete', async ($, e, next) => {
81    const result = await next(e)
82    await refresh($)
83
84    return result
85  })
86
87  on('tool.call', async ($, e, next) => {
88    const result = await next(e)
89    await refresh($)
90
91    return result
92  })
93
94  // The hint line under the prompt is the bottom-most row, so the bar lives there in both modes.
95  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
96    const snap = await read($, snapshot)
97    if (!(await read($, isOn)) || !snap) return next(e)
98
99    const { Box, Text } = $.ui.resolve(e)
100    const t = await read($, theme)
101    const columns = e.viewport?.columns ?? 80
102    const mobile = isMobile(await read($, mode), columns)
103    const hot = snap.percent >= 85
104    const warm = snap.percent >= 70
105    const level = hot ? 'error' : warm ? 'warning' : t.accent
106    const glyph = mobile ? MOBILE_GLYPH : GLYPH
107    const pct = `${Math.round(snap.percent)}%`
108    const tokens = `${compact(snap.total)}/${compact(snap.max)}`
109    // Mobile leads with the number: it is the only thing read at a glance on a phone.
110    const head = mobile ? `${pct.padStart(4)} ` : `◢ CONTEXT ${t.name} `
111    const tail = mobile
112      ? columns >= 40 ? ` ${compact(snap.total)}` : ''
113      : ` ${pct} ${tokens} ${e.props.isWorking ? '◉' : '◌'}`
114    const width = Math.max(8, columns - head.length - tail.length - 3)
115    const cells = cellsFor(snap.segments, snap.max, width)
116
117    return (
118      <Box>
119        <Text color={mobile ? level : t.accent} bold>{head}</Text>
120        <Text color={t.deep}>{mobile ? '▕' : '╞'}</Text>
121        {snap.segments.map((s, i) =>
122          cells[i] > 0 ? (
123            <Text key={s.name} color={s.color} dimColor={s.kind === 'free'}>
124              {glyph[s.kind].repeat(cells[i])}
125            </Text>
126          ) : null,
127        )}
128        <Text color={t.deep}>{mobile ? '▏' : '╡'}</Text>
129        <Text color={level} bold>{tail}</Text>
130      </Box>
131    )
132  })
133}
134
hooks/layout.ts 55 lines
1import type { Mode, Segment } from '../types'
2
3export const compact = (n: number) =>
4  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : `${n}`
5
6// Largest-remainder split of `width` cells across segments by token share.
7export const cellsFor = (segments: Segment[], max: number, width: number): number[] => {
8  const raw = segments.map(s => (max > 0 ? (Math.max(0, s.tokens) / max) * width : 0))
9  const cells = raw.map(Math.floor)
10  let spare = Math.min(width, Math.round(raw.reduce((a, b) => a + b, 0))) - cells.reduce((a, b) => a + b, 0)
11  const order = raw.map((r, i) => [r - Math.floor(r), i] as const).sort((a, b) => b[0] - a[0])
12
13  for (const [, i] of order) {
14    if (spare <= 0) break
15    cells[i] = (cells[i] ?? 0) + 1
16    spare -= 1
17  }
18  // keep any non-empty used category visible
19  segments.forEach((s, i) => {
20    if (s.kind === 'used' && s.tokens > 0 && cells[i] === 0) cells[i] = 1
21  })
22
23  return cells
24}
25
26// Termius on a phone is typically 36-50 columns portrait; auto switches below this.
27export const MOBILE_MAX_COLUMNS = 56
28
29export const isMobile = (mode: Mode, columns: number) =>
30  mode === 'mobile' || (mode === 'auto' && columns <= MOBILE_MAX_COLUMNS)
31
32// One-letter-ish tags so the legend fits a phone-width row.
33export const shortName = (name: string) => {
34  const known: Record<string, string> = {
35    'System prompt': 'Sys',
36    'System tools': 'Tools',
37    'MCP tools': 'MCP',
38    'Custom agents': 'Agents',
39    'Memory files': 'Mem',
40    Skills: 'Skills',
41    Messages: 'Msgs',
42  }
43
44  return known[name] ?? name.slice(0, 5)
45}
46
47export const nextMode = (arg: string, current: Mode): Mode | null => {
48  const word = arg.trim().toLowerCase()
49  if (word === 'mobile') return current === 'mobile' ? 'auto' : 'mobile'
50  if (word === 'desktop') return 'desktop'
51  if (word === 'auto') return 'auto'
52
53  return null
54}
55
hooks/theme.ts 26 lines
1import type { Theme } from '../types'
2
3// Per-distro palettes; unknown systems fall back to a neutral blue.
4const PALETTES: Record<string, Theme> = {
5  fedora: { name: 'Fedora', accent: '#51a2da', deep: '#294172', soft: '#8cc4ec' },
6  rhel: { name: 'RHEL', accent: '#4d8fd6', deep: '#1f3a66', soft: '#8ab4e8' },
7  arch: { name: 'Arch', accent: '#1793d1', deep: '#0b4f75', soft: '#6cc3ee' },
8  debian: { name: 'Debian', accent: '#5b8fd0', deep: '#2a4a7a', soft: '#9bbbe6' },
9  ubuntu: { name: 'Ubuntu', accent: '#5b8fd0', deep: '#2a4a7a', soft: '#9bbbe6' },
10  nixos: { name: 'NixOS', accent: '#5277c3', deep: '#2a3f73', soft: '#8fa8dc' },
11}
12
13export const DEFAULT_THEME: Theme = { name: 'Linux', accent: '#4f9ad8', deep: '#26416b', soft: '#8dbbe8' }
14
15export const themeFromOsRelease = (text: string): Theme => {
16  const field = (key: string) =>
17    text.match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1]?.replace(/^"|"$/g, '') ?? ''
18  const ids = [field('ID'), ...field('ID_LIKE').split(/\s+/)].map(s => s.toLowerCase())
19  const id = ids.find(one => one in PALETTES)
20  const base = (id ? PALETTES[id] : undefined) ?? DEFAULT_THEME
21  const version = field('VERSION_ID')
22  const name = field('NAME') || base.name
23
24  return { ...base, name: version ? `${name} ${version}` : name }
25}
26
types/index.d.ts 11 lines
1export type Segment = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' | 'deferred' }
2export type Snapshot = { segments: Segment[]; max: number; total: number; percent: number; model: string }
3export type Mode = 'auto' | 'mobile' | 'desktop'
4export type Theme = { name: string; accent: string; deep: string; soft: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'context-bar': { snapshot: Snapshot | null; isOn: boolean; mode: Mode; theme: Theme }
9  }
10}
11