diff --git a/.changeset/claude-status-bar-initial-release.md b/.changeset/claude-status-bar-initial-release.md new file mode 100644 index 0000000..30f5cda --- /dev/null +++ b/.changeset/claude-status-bar-initial-release.md @@ -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`. diff --git a/packages/status-bar/README.md b/packages/status-bar/README.md new file mode 100644 index 0000000..50e61a0 --- /dev/null +++ b/packages/status-bar/README.md @@ -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). diff --git a/packages/status-bar/__tests__/fixtures/reference-statusline.py b/packages/status-bar/__tests__/fixtures/reference-statusline.py new file mode 100644 index 0000000..ae94c20 --- /dev/null +++ b/packages/status-bar/__tests__/fixtures/reference-statusline.py @@ -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) diff --git a/packages/status-bar/__tests__/install-integration.test.ts b/packages/status-bar/__tests__/install-integration.test.ts new file mode 100644 index 0000000..ce2ab98 --- /dev/null +++ b/packages/status-bar/__tests__/install-integration.test.ts @@ -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) + }) +}) diff --git a/packages/status-bar/__tests__/install.test.ts b/packages/status-bar/__tests__/install.test.ts new file mode 100644 index 0000000..9ca7bc6 --- /dev/null +++ b/packages/status-bar/__tests__/install.test.ts @@ -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') + }) +}) diff --git a/packages/status-bar/__tests__/port-fidelity.test.ts b/packages/status-bar/__tests__/port-fidelity.test.ts new file mode 100644 index 0000000..1988454 --- /dev/null +++ b/packages/status-bar/__tests__/port-fidelity.test.ts @@ -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()) + }) +}) diff --git a/packages/status-bar/__tests__/render.test.ts b/packages/status-bar/__tests__/render.test.ts new file mode 100644 index 0000000..ba7132d --- /dev/null +++ b/packages/status-bar/__tests__/render.test.ts @@ -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') + }) +}) diff --git a/packages/status-bar/bin/davstack-status-bar.mjs b/packages/status-bar/bin/davstack-status-bar.mjs new file mode 100644 index 0000000..827ce20 --- /dev/null +++ b/packages/status-bar/bin/davstack-status-bar.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const entry = path.join(here, '..', 'dist', 'cli.js') +const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], { stdio: 'inherit' }) + +child.on('error', (err) => { + console.error('davstack-status-bar: launcher error:', err) + process.exit(1) +}) +child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal) + else process.exit(code ?? 0) +}) diff --git a/packages/status-bar/package.json b/packages/status-bar/package.json new file mode 100644 index 0000000..ca7278d --- /dev/null +++ b/packages/status-bar/package.json @@ -0,0 +1,37 @@ +{ + "name": "@davstack/claude-status-bar", + "version": "0.1.0", + "license": "MIT", + "type": "module", + "description": "Battery-like token usage status bar for Claude Code, installable via npx.", + "scripts": { + "build": "tsup", + "prepublishOnly": "tsup", + "test": "pnpm -w exec vitest run --project status-bar" + }, + "bin": { + "davstack-status-bar": "bin/davstack-status-bar.mjs" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "bin/**", + "dist/**", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/status-bar/src/cli.ts b/packages/status-bar/src/cli.ts new file mode 100644 index 0000000..cba4305 --- /dev/null +++ b/packages/status-bar/src/cli.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { renderStatusLine, type StatusLineInput } from './render.js' +import { runInstall, runUninstall } from './install.js' + +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + return Buffer.concat(chunks).toString('utf8') +} + +async function render() { + const raw = await readStdin() + const input = JSON.parse(raw) as StatusLineInput + process.stdout.write(renderStatusLine(input, Date.now() / 1000) + '\n') +} + +const KNOWN_COMMANDS = ['install', 'uninstall', 'render'] + +async function main() { + const argv = process.argv.slice(2) + // A bare `--force`/`--dir` with no leading command word (e.g. running + // `npx @davstack/claude-status-bar --force`) implies `install`. + const hasExplicitCommand = argv[0] !== undefined && KNOWN_COMMANDS.includes(argv[0]) + const command = hasExplicitCommand ? argv[0] : undefined + const rest = hasExplicitCommand ? argv.slice(1) : argv + + if (command === 'install') { + await runInstall(rest) + return + } + if (command === 'uninstall') { + await runUninstall(rest) + return + } + if (command === 'render') { + await render() + return + } + + // No recognized command: running interactively (a TTY) means the user + // launched the CLI directly to install; otherwise treat stdin as the + // statusLine payload (this is what settings.json actually invokes). + if (process.stdin.isTTY) { + await runInstall(rest) + } else { + await render() + } +} + +main().catch((err) => { + console.error('davstack-status-bar:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/packages/status-bar/src/index.ts b/packages/status-bar/src/index.ts new file mode 100644 index 0000000..7de38ad --- /dev/null +++ b/packages/status-bar/src/index.ts @@ -0,0 +1,4 @@ +export { renderStatusLine } from './render.js' +export type { StatusLineInput, ContextWindow, RateLimitWindow } from './render.js' +export { mergeSettings, removeSettings, isOurCommand, RUNTIME_FILENAME } from './install.js' +export type { Settings, StatusLineConfig, MergeStatus } from './install.js' diff --git a/packages/status-bar/src/install.ts b/packages/status-bar/src/install.ts new file mode 100644 index 0000000..014f7a6 --- /dev/null +++ b/packages/status-bar/src/install.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const RUNTIME_FILENAME = 'davstack-status-bar.mjs' + +export interface StatusLineConfig { + type: string + command: string +} + +export interface Settings { + statusLine?: StatusLineConfig + [key: string]: unknown +} + +export function isOurCommand(command: string | undefined, runtimeName: string): boolean { + if (!command) return false + return command.includes(runtimeName) +} + +export type MergeStatus = 'added' | 'updated' | 'unchanged' | 'conflict' + +export function mergeSettings( + existing: Settings, + ourCommand: string, + runtimeName: string, +): { settings: Settings; status: MergeStatus } { + const settings: Settings = { ...existing } + const current = settings.statusLine + + if (!current) { + settings.statusLine = { type: 'command', command: ourCommand } + return { settings, status: 'added' } + } + if (current.command === ourCommand) { + return { settings, status: 'unchanged' } + } + if (isOurCommand(current.command, runtimeName)) { + settings.statusLine = { type: 'command', command: ourCommand } + return { settings, status: 'updated' } + } + return { settings, status: 'conflict' } +} + +export function removeSettings( + existing: Settings, + runtimeName: string, +): { settings: Settings; removed: boolean } { + const settings: Settings = { ...existing } + const current = settings.statusLine + if (current && isOurCommand(current.command, runtimeName)) { + delete settings.statusLine + return { settings, removed: true } + } + return { settings, removed: false } +} + +function parseArgs(args: string[]) { + let dir: string | undefined + let force = false + for (let i = 0; i < args.length; i++) { + if (args[i] === '--dir') { + dir = args[++i] + } else if (args[i] === '--force') { + force = true + } + } + return { dir: dir ?? path.join(os.homedir(), '.claude'), force } +} + +function readSettings(settingsPath: string): Settings { + if (!fs.existsSync(settingsPath)) return {} + const raw = fs.readFileSync(settingsPath, 'utf8') + if (!raw.trim()) return {} + return JSON.parse(raw) as Settings +} + +function writeSettings(settingsPath: string, settings: Settings) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8') +} + +function backupSettings(claudeDir: string, settingsPath: string) { + if (!fs.existsSync(settingsPath)) return + const backupsDir = path.join(claudeDir, 'backups') + fs.mkdirSync(backupsDir, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + fs.copyFileSync(settingsPath, path.join(backupsDir, `settings.json.${stamp}.bak`)) +} + +function runtimeSourcePath(): string { + const here = path.dirname(fileURLToPath(import.meta.url)) + // Production: this module runs compiled as dist/install.js, sibling to dist/runtime.js. + const sibling = path.join(here, 'runtime.js') + if (fs.existsSync(sibling)) return sibling + // Dev/test: this module runs from src/install.ts against the built dist/ next door. + return path.join(here, '..', 'dist', 'runtime.js') +} + +export async function runInstall(args: string[]) { + const { dir, force } = parseArgs(args) + fs.mkdirSync(dir, { recursive: true }) + + const settingsPath = path.join(dir, 'settings.json') + const runtimePath = path.join(dir, RUNTIME_FILENAME) + const ourCommand = `node "${runtimePath}"` + + const existing = readSettings(settingsPath) + let { settings, status } = mergeSettings(existing, ourCommand, RUNTIME_FILENAME) + + if (status === 'conflict') { + if (!force) { + console.error( + `davstack-status-bar: settings.json already has a statusLine command that isn't ours:\n` + + ` ${existing.statusLine?.command}\n` + + `Re-run with --force to overwrite it (a backup will be written to ${path.join(dir, 'backups')}).`, + ) + process.exitCode = 1 + return + } + settings = { ...settings, statusLine: { type: 'command', command: ourCommand } } + status = 'updated' + } + + if (status !== 'unchanged') { + backupSettings(dir, settingsPath) + writeSettings(settingsPath, settings) + } + + fs.copyFileSync(runtimeSourcePath(), runtimePath) + + console.log(`davstack-status-bar: installed to ${runtimePath}`) + if (status === 'unchanged') console.log('settings.json already pointed at this script; runtime refreshed.') + else console.log(`settings.json statusLine ${status}.`) +} + +export async function runUninstall(args: string[]) { + const { dir } = parseArgs(args) + const settingsPath = path.join(dir, 'settings.json') + const runtimePath = path.join(dir, RUNTIME_FILENAME) + + const existing = readSettings(settingsPath) + const { settings, removed } = removeSettings(existing, RUNTIME_FILENAME) + + if (removed) { + backupSettings(dir, settingsPath) + writeSettings(settingsPath, settings) + } + + if (fs.existsSync(runtimePath)) fs.rmSync(runtimePath) + + console.log( + removed + ? `davstack-status-bar: removed statusLine from ${settingsPath} and deleted ${runtimePath}.` + : `davstack-status-bar: no statusLine of ours found in ${settingsPath}; deleted ${runtimePath} if present.`, + ) +} diff --git a/packages/status-bar/src/render.ts b/packages/status-bar/src/render.ts new file mode 100644 index 0000000..6400a26 --- /dev/null +++ b/packages/status-bar/src/render.ts @@ -0,0 +1,54 @@ +const ESC = '\x1b' +const CAP = 350_000 + +export interface ContextWindow { + total_input_tokens?: number + total_output_tokens?: number +} + +export interface RateLimitWindow { + used_percentage?: number + resets_at?: number +} + +export interface StatusLineInput { + context_window?: ContextWindow + rate_limits?: { five_hour?: RateLimitWindow } +} + +function colorForTokens(tokens: number): number | null { + if (tokens >= 350_000) return 202 + if (tokens >= 300_000) return 208 + if (tokens >= 250_000) return 220 + if (tokens >= 200_000) return 229 + return null +} + +export function renderStatusLine(input: StatusLineInput, nowSeconds: number): string { + const cw = input.context_window ?? {} + const tokens = (cw.total_input_tokens ?? 0) + (cw.total_output_tokens ?? 0) + + const pct = Math.min(tokens / CAP, 1.0) + const filled = Math.min(Math.floor(pct * 10), 10) + let bar = '█'.repeat(filled) + '░'.repeat(10 - filled) + + const color = colorForTokens(tokens) + if (color !== null) { + bar = `${ESC}[38;5;${color}m${bar}${ESC}[0m` + } + + const k = Math.round(tokens / 1000) + let out = `${bar} ${k}k tokens` + + const rl = input.rate_limits?.five_hour ?? {} + const rlPct = rl.used_percentage + const resetsAt = rl.resets_at + if (rlPct !== undefined && rlPct !== null && resetsAt !== undefined && resetsAt !== null) { + const remaining = Math.max(0, Math.floor(resetsAt - nowSeconds)) + const h = Math.floor(remaining / 3600) + const m = Math.floor((remaining % 3600) / 60) + out += ` ${ESC}[38;5;240m| ${Math.round(rlPct)}% · ${h}:${String(m).padStart(2, '0')}h${ESC}[0m` + } + + return out +} diff --git a/packages/status-bar/src/runtime.ts b/packages/status-bar/src/runtime.ts new file mode 100644 index 0000000..e25d2be --- /dev/null +++ b/packages/status-bar/src/runtime.ts @@ -0,0 +1,13 @@ +import { renderStatusLine, type StatusLineInput } from './render.js' + +async function main() { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) chunks.push(chunk as Buffer) + const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as StatusLineInput + process.stdout.write(renderStatusLine(input, Date.now() / 1000) + '\n') +} + +main().catch((err) => { + console.error('davstack-status-bar:', err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/packages/status-bar/tsconfig.json b/packages/status-bar/tsconfig.json new file mode 100644 index 0000000..9865451 --- /dev/null +++ b/packages/status-bar/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "resolveJsonModule": true + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/packages/status-bar/tsup.config.ts b/packages/status-bar/tsup.config.ts new file mode 100644 index 0000000..725447d --- /dev/null +++ b/packages/status-bar/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: { + index: "src/index.ts", + cli: "src/cli.ts", + install: "src/install.ts", + render: "src/render.ts", + runtime: "src/runtime.ts", + }, + format: ["esm"], + target: "node20", + outDir: "dist", + dts: true, + clean: true, + splitting: false, + sourcemap: true, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cd2e3f..4b16f04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,18 @@ importers: specifier: ^5.0.0 version: 5.3.3 + packages/status-bar: + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + tsup: + specifier: ^8.0.0 + version: 8.5.1(tsx@4.22.3)(typescript@5.3.3) + typescript: + specifier: ^5.0.0 + version: 5.3.3 + packages/tui: dependencies: '@davstack/open-agents': diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 0d9dd4c..cf5ae38 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -84,6 +84,16 @@ export default defineWorkspace([ exclude: ['**/node_modules/**'], }, }, + { + resolve: { alias: { vitest: vitestAlias } }, + test: { + name: 'status-bar', + root: './packages/status-bar', + environment: 'node', + include: ['__tests__/**/*.test.ts'], + exclude: ['**/node_modules/**'], + }, + }, { resolve: { alias: { vitest: vitestAlias } }, test: {