Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claude-status-bar-initial-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@davstack/claude-status-bar": minor
---

Initial release: a battery-like token usage status bar for Claude Code, installable via `npx @davstack/claude-status-bar`.
47 changes: 47 additions & 0 deletions packages/status-bar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# @davstack/claude-status-bar

A battery-like token-usage status bar for [Claude Code](https://claude.com/claude-code): a
10-segment bar that fills and shifts from yellow to orange as you approach the context
window cap, plus the current 5-hour rate-limit usage and time-to-reset.

## Install

```sh
npx @davstack/claude-status-bar
```

This writes a self-contained runtime script to `~/.claude/davstack-status-bar.mjs` and adds
a `statusLine` entry to `~/.claude/settings.json`. It won't touch any other key in that file,
and it backs up your existing `settings.json` to `~/.claude/backups/` before changing it.

If you already have a different `statusLine` configured, the installer refuses to overwrite it:

```sh
npx @davstack/claude-status-bar --force # overwrite an existing statusLine
```

## Uninstall

```sh
npx @davstack/claude-status-bar uninstall
```

Removes the `statusLine` entry (only if it's still ours) and deletes the installed script.

## Manual install

If you'd rather not run an npx script against your config, do it by hand:

1. Copy [`dist/runtime.js`](./dist/runtime.js) from this package to `~/.claude/davstack-status-bar.mjs`.
2. Add this to `~/.claude/settings.json`:
```json
"statusLine": {
"type": "command",
"command": "node \"~/.claude/davstack-status-bar.mjs\""
}
```

## Requirements

Node.js >= 20 on the machine running Claude Code (used only for the status line command
itself — no dependency on Python).
44 changes: 44 additions & 0 deletions packages/status-bar/__tests__/fixtures/reference-statusline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# Frozen copy of the original ~/.claude/statusline.py this package ports to
# TypeScript, used only to verify byte-for-byte parity in port-fidelity.test.ts.
import json, sys, io, time

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

CAP = 350_000

data = json.load(sys.stdin)
cw = data.get('context_window') or {}
tokens = (cw.get('total_input_tokens') or 0) + (cw.get('total_output_tokens') or 0)

pct = min(tokens / CAP, 1.0)
filled = min(int(pct * 10), 10)
bar = '█' * filled + '░' * (10 - filled)

if tokens >= 350_000:
color = 202
elif tokens >= 300_000:
color = 208
elif tokens >= 250_000:
color = 220
elif tokens >= 200_000:
color = 229
else:
color = None

if color is not None:
bar = f"\033[38;5;{color}m{bar}\033[0m"

k = round(tokens / 1000)
out = f"{bar} {k}k tokens"

rl = (data.get('rate_limits') or {}).get('five_hour') or {}
rl_pct = rl.get('used_percentage')
resets_at = rl.get('resets_at')
if rl_pct is not None and resets_at is not None:
remaining = max(0, int(resets_at - time.time()))
h = remaining // 3600
m = (remaining % 3600) // 60
out += f" \033[38;5;240m| {round(rl_pct)}% · {h}:{m:02d}h\033[0m"

print(out)
101 changes: 101 additions & 0 deletions packages/status-bar/__tests__/install-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runInstall, runUninstall, RUNTIME_FILENAME } from '../src/install.js'

let tmpDir: string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'status-bar-test-'))
})

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})

describe('install / uninstall round-trip', () => {
it('writes settings.json and the runtime script into a fresh dir', async () => {
await runInstall(['--dir', tmpDir])

const settingsPath = path.join(tmpDir, 'settings.json')
const runtimePath = path.join(tmpDir, RUNTIME_FILENAME)
expect(fs.existsSync(settingsPath)).toBe(true)
expect(fs.existsSync(runtimePath)).toBe(true)

const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
expect(settings.statusLine.command).toContain(RUNTIME_FILENAME)
})

it('preserves unrelated settings.json keys and backs the file up', async () => {
fs.writeFileSync(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ model: 'sonnet', permissions: { allow: ['Bash(ls:*)'] } }, null, 2),
)

await runInstall(['--dir', tmpDir])

const settings = JSON.parse(fs.readFileSync(path.join(tmpDir, 'settings.json'), 'utf8'))
expect(settings.model).toBe('sonnet')
expect(settings.permissions.allow).toEqual(['Bash(ls:*)'])
expect(settings.statusLine).toBeDefined()

const backups = fs.readdirSync(path.join(tmpDir, 'backups'))
expect(backups.length).toBe(1)
})

it('refuses to overwrite a foreign statusLine without --force', async () => {
fs.writeFileSync(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'python ~/.claude/statusline.py' } }, null, 2),
)

await runInstall(['--dir', tmpDir])

const settings = JSON.parse(fs.readFileSync(path.join(tmpDir, 'settings.json'), 'utf8'))
expect(settings.statusLine.command).toBe('python ~/.claude/statusline.py')
})

it('overwrites a foreign statusLine with --force', async () => {
fs.writeFileSync(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ statusLine: { type: 'command', command: 'python ~/.claude/statusline.py' } }, null, 2),
)

await runInstall(['--dir', tmpDir, '--force'])

const settings = JSON.parse(fs.readFileSync(path.join(tmpDir, 'settings.json'), 'utf8'))
expect(settings.statusLine.command).toContain(RUNTIME_FILENAME)
})

it('is idempotent: installing twice does not duplicate backups', async () => {
await runInstall(['--dir', tmpDir])
await runInstall(['--dir', tmpDir])

const backupsDir = path.join(tmpDir, 'backups')
const backups = fs.existsSync(backupsDir) ? fs.readdirSync(backupsDir) : []
expect(backups.length).toBe(0)
})

it('uninstall removes our statusLine and the runtime file, leaving other keys', async () => {
await runInstall(['--dir', tmpDir])
fs.writeFileSync(
path.join(tmpDir, 'settings.json'),
JSON.stringify(
{
model: 'sonnet',
statusLine: JSON.parse(fs.readFileSync(path.join(tmpDir, 'settings.json'), 'utf8')).statusLine,
},
null,
2,
),
)

await runUninstall(['--dir', tmpDir])

const settings = JSON.parse(fs.readFileSync(path.join(tmpDir, 'settings.json'), 'utf8'))
expect(settings.statusLine).toBeUndefined()
expect(settings.model).toBe('sonnet')
expect(fs.existsSync(path.join(tmpDir, RUNTIME_FILENAME))).toBe(false)
})
})
73 changes: 73 additions & 0 deletions packages/status-bar/__tests__/install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { isOurCommand, mergeSettings, removeSettings } from '../src/install.js'

const RUNTIME = 'davstack-status-bar.mjs'
const OUR_COMMAND = 'node "/home/user/.claude/davstack-status-bar.mjs"'

describe('isOurCommand', () => {
it('is false when there is no command', () => {
expect(isOurCommand(undefined, RUNTIME)).toBe(false)
})

it('is true when the command references our runtime filename', () => {
expect(isOurCommand(OUR_COMMAND, RUNTIME)).toBe(true)
})

it('is false for an unrelated command', () => {
expect(isOurCommand('python ~/.claude/statusline.py', RUNTIME)).toBe(false)
})
})

describe('mergeSettings', () => {
it('adds statusLine when none exists, leaving other keys untouched', () => {
const { settings, status } = mergeSettings({ model: 'sonnet' }, OUR_COMMAND, RUNTIME)
expect(status).toBe('added')
expect(settings.model).toBe('sonnet')
expect(settings.statusLine).toEqual({ type: 'command', command: OUR_COMMAND })
})

it('is unchanged when our command is already installed', () => {
const existing = { statusLine: { type: 'command', command: OUR_COMMAND } }
const { settings, status } = mergeSettings(existing, OUR_COMMAND, RUNTIME)
expect(status).toBe('unchanged')
expect(settings.statusLine?.command).toBe(OUR_COMMAND)
})

it('updates when an older version of our own command is installed', () => {
const olderCommand = 'node "/home/user/.claude/davstack-status-bar.mjs" --legacy'
const existing = { statusLine: { type: 'command', command: olderCommand } }
const { settings, status } = mergeSettings(existing, OUR_COMMAND, RUNTIME)
expect(status).toBe('updated')
expect(settings.statusLine?.command).toBe(OUR_COMMAND)
})

it('reports a conflict without touching a foreign statusLine', () => {
const existing = { statusLine: { type: 'command', command: 'python ~/.claude/statusline.py' } }
const { settings, status } = mergeSettings(existing, OUR_COMMAND, RUNTIME)
expect(status).toBe('conflict')
expect(settings.statusLine?.command).toBe('python ~/.claude/statusline.py')
})
})

describe('removeSettings', () => {
it('removes statusLine when it is ours', () => {
const existing = { model: 'sonnet', statusLine: { type: 'command', command: OUR_COMMAND } }
const { settings, removed } = removeSettings(existing, RUNTIME)
expect(removed).toBe(true)
expect(settings.statusLine).toBeUndefined()
expect(settings.model).toBe('sonnet')
})

it('leaves a foreign statusLine alone', () => {
const existing = { statusLine: { type: 'command', command: 'python ~/.claude/statusline.py' } }
const { settings, removed } = removeSettings(existing, RUNTIME)
expect(removed).toBe(false)
expect(settings.statusLine?.command).toBe('python ~/.claude/statusline.py')
})

it('is a no-op when there is no statusLine at all', () => {
const { settings, removed } = removeSettings({ model: 'sonnet' }, RUNTIME)
expect(removed).toBe(false)
expect(settings.model).toBe('sonnet')
})
})
45 changes: 45 additions & 0 deletions packages/status-bar/__tests__/port-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { renderStatusLine } from '../src/render.js'

const here = path.dirname(fileURLToPath(import.meta.url))
const pyScript = path.join(here, 'fixtures', 'reference-statusline.py')

function pythonAvailable(): string | null {
for (const bin of ['python3', 'python']) {
const res = spawnSync(bin, ['--version'])
if (res.status === 0) return bin
}
return null
}

function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, '')
}

const python = pythonAvailable()

describe.skipIf(!python)('port fidelity vs statusline.py', () => {
const cases = [
{ context_window: { total_input_tokens: 0, total_output_tokens: 0 } },
{ context_window: { total_input_tokens: 50_000, total_output_tokens: 25_000 } },
{ context_window: { total_input_tokens: 200_000, total_output_tokens: 5_000 } },
{ context_window: { total_input_tokens: 500_000, total_output_tokens: 0 } },
]

it.each(cases)('matches for %j (excluding rate-limit segment)', (input) => {
const pyResult = spawnSync(python as string, [pyScript], {
input: JSON.stringify(input),
encoding: 'utf8',
})
expect(pyResult.status).toBe(0)

const tsOut = renderStatusLine(input, Date.now() / 1000)

// The python script isn't injectable with a fixed "now", but neither
// case includes rate_limits, so both outputs should match exactly.
expect(stripAnsi(tsOut).trim()).toBe(stripAnsi(pyResult.stdout).trim())
})
})
69 changes: 69 additions & 0 deletions packages/status-bar/__tests__/render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import { renderStatusLine } from '../src/render.js'

const NOW = 1_700_000_000

describe('renderStatusLine', () => {
it('renders an empty bar with no tokens', () => {
const out = renderStatusLine({}, NOW)
expect(out).toBe('░░░░░░░░░░ 0k tokens')
})

it('renders a partially filled bar with no color below 200k', () => {
const out = renderStatusLine(
{ context_window: { total_input_tokens: 50_000, total_output_tokens: 25_000 } },
NOW,
)
expect(out).toBe('██░░░░░░░░ 75k tokens')
})

it('colors the bar yellow-ish at 200k', () => {
const out = renderStatusLine({ context_window: { total_input_tokens: 200_000 } }, NOW)
expect(out).toBe('\x1b[38;5;229m█████░░░░░\x1b[0m 200k tokens')
})

it('colors the bar orange at 250k, 300k, and 350k+', () => {
expect(renderStatusLine({ context_window: { total_input_tokens: 250_000 } }, NOW)).toContain(
'\x1b[38;5;220m',
)
expect(renderStatusLine({ context_window: { total_input_tokens: 300_000 } }, NOW)).toContain(
'\x1b[38;5;208m',
)
expect(renderStatusLine({ context_window: { total_input_tokens: 350_000 } }, NOW)).toContain(
'\x1b[38;5;202m',
)
})

it('caps the bar fill at 10 blocks past the cap', () => {
const out = renderStatusLine({ context_window: { total_input_tokens: 500_000 } }, NOW)
expect(out).toContain('██████████')
expect(out).toContain('500k tokens')
})

it('omits the rate-limit segment when absent', () => {
const out = renderStatusLine({ context_window: { total_input_tokens: 1000 } }, NOW)
expect(out).not.toContain('%')
})

it('appends the rate-limit segment when present', () => {
const out = renderStatusLine(
{
context_window: { total_input_tokens: 1000 },
rate_limits: { five_hour: { used_percentage: 42.4, resets_at: NOW + 3661 } },
},
NOW,
)
expect(out).toContain('\x1b[38;5;240m| 42% · 1:01h\x1b[0m')
})

it('clamps remaining time at zero when resets_at is in the past', () => {
const out = renderStatusLine(
{
context_window: { total_input_tokens: 1000 },
rate_limits: { five_hour: { used_percentage: 10, resets_at: NOW - 100 } },
},
NOW,
)
expect(out).toContain('| 10% · 0:00h')
})
})
Loading