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

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
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 | Effect |
|---|---|
/context-bar | Toggle the bar on/off |
/context-bar mobile | Toggle mobile layout (toggling again returns to auto) |
/context-bar desktop | Force the full desktop layout |
/context-bar auto | Choose by width: mobile at 56 columns or fewer (the default) |
Choosing a layout also turns the bar on.
◢ 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.▕…▏. 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.█, 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./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).
| Role | Nobara | Other distros |
|---|---|---|
| Accent / cool heat | #7289D9 | #5B8DEF |
| Brackets | #2671B2 | #3B6FB6 |
| Header glyph / hot heat | #F96855 (coral) | #5B8DEF / theme error |
| Warm heat | theme warning | theme 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.
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.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.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.
hooks/register.tsx 133 lines1import { 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}
133hooks/lib/layout.ts 136 lines1// 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}
136hooks/lib/mode.ts 33 lines1// /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}
33hooks/lib/osrelease.ts 74 lines1// /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}
74hooks/lib/snapshot.ts 26 lines1// 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}
26hooks/lib/cells.ts 70 lines1// 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}
70hooks/lib/format.ts 23 lines1// 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}
23hooks/lib/theme.ts 31 lines1// 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}
31types/index.d.ts 37 lines1export 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