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

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>.
| Mod | What it does |
|---|---|
| context-bar | Stacked 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-bar | The Nobara port: same idea, Nobara-themed, with a mobile layout for Termius. Built on dominus-nobara. |
| cyberdeck | Model/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.
Load with claude --plugin-dir ./context-bar.
/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.
See nobara-bar/README.md. Its full build spec is prompts/nobara-bar.md.
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.
hooks/register.tsx 134 lines1import { 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}
134hooks/layout.ts 55 lines1import 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}
55hooks/theme.ts 26 lines1import 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}
26types/index.d.ts 11 lines1export 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