meter-ai 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ import { EventEmitter } from 'events'
2
+ import { spawn as cpSpawn, type ChildProcess } from 'child_process'
3
+ import { AlternateScreenTracker } from './screen.js'
4
+ import { getTerminalSize } from './resize.js'
5
+
6
+ export interface WrapperEvents {
7
+ data: (chunk: string) => void
8
+ input: (prompt: string) => void
9
+ exit: (code: number, signal: number) => void
10
+ }
11
+
12
+ type PtyModule = typeof import('node-pty')
13
+
14
+ /**
15
+ * PtyWrapper with automatic fallback and input capture.
16
+ *
17
+ * In PTY mode:
18
+ * - Captures all user input keystroke by keystroke
19
+ * - Buffers input and emits 'input' event on Enter (prompt submission)
20
+ * - This enables per-prompt estimation during interactive sessions
21
+ *
22
+ * In fallback mode:
23
+ * - stdio is inherited, no input capture possible
24
+ */
25
+ export class PtyWrapper extends EventEmitter {
26
+ private ptyProcess: import('node-pty').IPty | null = null
27
+ private childProcess: ChildProcess | null = null
28
+ private screenTracker = new AlternateScreenTracker()
29
+ private _usingFallback = false
30
+ private inputBuffer = ''
31
+
32
+ get isInAlternateScreen(): boolean {
33
+ return this.screenTracker.isActive
34
+ }
35
+
36
+ get usingFallback(): boolean {
37
+ return this._usingFallback
38
+ }
39
+
40
+ spawn(binary: string, args: string[], env: NodeJS.ProcessEnv): void {
41
+ try {
42
+ const pty: PtyModule = require('node-pty')
43
+ this._spawnWithPty(pty, binary, args, env)
44
+ } catch {
45
+ this._usingFallback = true
46
+ this._spawnWithChildProcess(binary, args, env)
47
+ }
48
+ }
49
+
50
+ private _spawnWithPty(pty: PtyModule, binary: string, args: string[], env: NodeJS.ProcessEnv): void {
51
+ const size = getTerminalSize()
52
+
53
+ this.ptyProcess = pty.spawn(binary, args, {
54
+ name: process.env.TERM ?? 'xterm-256color',
55
+ cols: size.cols,
56
+ rows: size.rows,
57
+ env: { ...env },
58
+ cwd: process.cwd(),
59
+ })
60
+
61
+ this.ptyProcess.onData((data: string) => {
62
+ this.screenTracker.process(data)
63
+ this.emit('data', data)
64
+ process.stdout.write(data)
65
+ })
66
+
67
+ this.ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
68
+ this.emit('exit', exitCode, signal ?? 0)
69
+ })
70
+
71
+ // Forward stdin to PTY and capture input for per-prompt estimation
72
+ if (process.stdin.isTTY) {
73
+ process.stdin.setRawMode(true)
74
+ }
75
+ process.stdin.on('data', (data: Buffer) => {
76
+ const str = data.toString()
77
+ this.ptyProcess?.write(str)
78
+
79
+ // Track user input for prompt detection
80
+ for (const char of str) {
81
+ if (char === '\r' || char === '\n') {
82
+ // Enter pressed — emit the buffered input as a prompt
83
+ const prompt = this.inputBuffer.trim()
84
+ if (prompt.length > 0) {
85
+ this.emit('input', prompt)
86
+ }
87
+ this.inputBuffer = ''
88
+ } else if (char === '\x7f' || char === '\b') {
89
+ // Backspace — remove last char from buffer
90
+ this.inputBuffer = this.inputBuffer.slice(0, -1)
91
+ } else if (char === '\x03') {
92
+ // Ctrl+C — clear buffer
93
+ this.inputBuffer = ''
94
+ } else if (char === '\x15') {
95
+ // Ctrl+U — clear line
96
+ this.inputBuffer = ''
97
+ } else if (char.charCodeAt(0) >= 32) {
98
+ // Printable character — append to buffer
99
+ this.inputBuffer += char
100
+ }
101
+ // Ignore other control characters (arrows, escape sequences, etc.)
102
+ }
103
+ })
104
+
105
+ // Handle terminal resize
106
+ process.on('SIGWINCH', () => {
107
+ const newSize = getTerminalSize()
108
+ this.ptyProcess?.resize(newSize.cols, newSize.rows)
109
+ })
110
+ }
111
+
112
+ private _spawnWithChildProcess(binary: string, args: string[], env: NodeJS.ProcessEnv): void {
113
+ this.childProcess = cpSpawn(binary, args, {
114
+ env: { ...env },
115
+ cwd: process.cwd(),
116
+ stdio: 'inherit',
117
+ })
118
+
119
+ this.childProcess.on('exit', (code, signal) => {
120
+ this.emit('exit', code ?? 1, signal ? 1 : 0)
121
+ })
122
+ }
123
+
124
+ write(data: string): void {
125
+ if (this.ptyProcess) {
126
+ this.ptyProcess.write(data)
127
+ } else if (this.childProcess) {
128
+ this.childProcess.stdin?.write(data)
129
+ }
130
+ }
131
+
132
+ kill(signal: NodeJS.Signals = 'SIGTERM'): void {
133
+ if (this.ptyProcess) {
134
+ this.ptyProcess.kill(signal)
135
+ } else if (this.childProcess) {
136
+ this.childProcess.kill(signal)
137
+ }
138
+ }
139
+
140
+ resize(cols: number, rows: number): void {
141
+ this.ptyProcess?.resize(cols, rows)
142
+ }
143
+ }
@@ -0,0 +1,21 @@
1
+ import { access, constants } from 'fs/promises'
2
+ import { join } from 'path'
3
+
4
+ export async function resolveTrueBinary(
5
+ name: string,
6
+ skipDir: string
7
+ ): Promise<string | null> {
8
+ const pathEnv = process.env.PATH ?? ''
9
+ const dirs = pathEnv.split(':').filter(d => d !== skipDir && d !== '')
10
+
11
+ for (const dir of dirs) {
12
+ const fullPath = join(dir, name)
13
+ try {
14
+ await access(fullPath, constants.X_OK)
15
+ return fullPath
16
+ } catch {
17
+ // not found or not executable, continue
18
+ }
19
+ }
20
+ return null
21
+ }
@@ -0,0 +1,33 @@
1
+ import { homedir } from 'os'
2
+ import { join } from 'path'
3
+
4
+ export type ShellType = 'zsh' | 'bash' | 'fish' | 'nushell'
5
+
6
+ export function detectShell(): ShellType {
7
+ const shell = process.env.SHELL ?? ''
8
+ if (shell.includes('zsh')) return 'zsh'
9
+ if (shell.includes('fish')) return 'fish'
10
+ if (shell.includes('nu')) return 'nushell'
11
+ return 'bash'
12
+ }
13
+
14
+ export function getShellConfigPath(shell: ShellType): string {
15
+ const home = homedir()
16
+ const paths: Record<ShellType, string> = {
17
+ zsh: join(home, '.zshrc'),
18
+ bash: join(home, '.bashrc'),
19
+ fish: join(home, '.config', 'fish', 'config.fish'),
20
+ nushell: join(home, '.config', 'nushell', 'env.nu'),
21
+ }
22
+ return paths[shell]
23
+ }
24
+
25
+ export function getPathInjectLine(shell: ShellType): string {
26
+ const lines: Record<ShellType, string> = {
27
+ zsh: `export PATH="$HOME/.meter/bin:$PATH" # added by meter`,
28
+ bash: `export PATH="$HOME/.meter/bin:$PATH" # added by meter`,
29
+ fish: `fish_add_path ~/.meter/bin # added by meter`,
30
+ nushell: `$env.PATH = ($env.PATH | prepend ($env.HOME + "/.meter/bin")) # added by meter`,
31
+ }
32
+ return lines[shell]
33
+ }
@@ -0,0 +1,31 @@
1
+ import { readFile, writeFile, appendFile } from 'fs/promises'
2
+ import type { ShellType } from './detect.js'
3
+ import { getPathInjectLine } from './detect.js'
4
+
5
+ const METER_MARKER = '# added by meter'
6
+
7
+ export async function isPathAlreadyInjected(configPath: string): Promise<boolean> {
8
+ try {
9
+ const content = await readFile(configPath, 'utf-8')
10
+ return content.includes(METER_MARKER)
11
+ } catch {
12
+ return false
13
+ }
14
+ }
15
+
16
+ export async function injectPath(shell: ShellType, configPath: string): Promise<void> {
17
+ const already = await isPathAlreadyInjected(configPath)
18
+ if (already) return
19
+ const line = getPathInjectLine(shell)
20
+ await appendFile(configPath, `\n${line}\n`, 'utf-8')
21
+ }
22
+
23
+ export async function removePath(configPath: string): Promise<void> {
24
+ try {
25
+ const content = await readFile(configPath, 'utf-8')
26
+ const cleaned = content.split('\n').filter(line => !line.includes(METER_MARKER)).join('\n')
27
+ await writeFile(configPath, cleaned, 'utf-8')
28
+ } catch {
29
+ // file may not exist, that's fine
30
+ }
31
+ }
@@ -0,0 +1,28 @@
1
+ import { writeFile, mkdir, chmod, access } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+
4
+ const SHIM_EXEC_TEMPLATE = (trueBinary: string, meterBin: string) =>
5
+ `#!/bin/sh
6
+ # meter shim — do not edit manually
7
+ exec "${meterBin}" wrap "${trueBinary}" "$@"
8
+ `
9
+
10
+ export async function writeShim(
11
+ shimPath: string,
12
+ trueBinary: string,
13
+ meterEntrypoint?: string
14
+ ): Promise<void> {
15
+ await mkdir(dirname(shimPath), { recursive: true })
16
+ const content = SHIM_EXEC_TEMPLATE(trueBinary, meterEntrypoint ?? 'meter')
17
+ await writeFile(shimPath, content, 'utf-8')
18
+ await chmod(shimPath, 0o755)
19
+ }
20
+
21
+ export async function shimExists(shimPath: string): Promise<boolean> {
22
+ try {
23
+ await access(shimPath)
24
+ return true
25
+ } catch {
26
+ return false
27
+ }
28
+ }
@@ -0,0 +1,46 @@
1
+ import { readFile, writeFile, mkdir } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+ import { DEFAULT_CONFIG_VALUES } from '../constants.js'
4
+ import type { MeterConfig, UserMode } from '../types.js'
5
+
6
+ export async function readConfig(path: string): Promise<MeterConfig | null> {
7
+ try {
8
+ const raw = await readFile(path, 'utf-8')
9
+ return JSON.parse(raw) as MeterConfig
10
+ } catch {
11
+ return null
12
+ }
13
+ }
14
+
15
+ export async function writeConfig(path: string, config: MeterConfig): Promise<void> {
16
+ await mkdir(dirname(path), { recursive: true })
17
+ await writeFile(path, JSON.stringify(config, null, 2), 'utf-8')
18
+ }
19
+
20
+ export function ensureConfigDefaults(
21
+ partial: Partial<MeterConfig> & { mode: UserMode; resolved_binaries: { claude: string } }
22
+ ): MeterConfig {
23
+ return {
24
+ version: 1,
25
+ org_id: partial.org_id,
26
+ budget: {
27
+ per_task_usd: DEFAULT_CONFIG_VALUES.budget_per_task_usd,
28
+ threshold_pct: DEFAULT_CONFIG_VALUES.threshold_pct,
29
+ action: 'notify',
30
+ },
31
+ plan: {
32
+ window_threshold_pct: DEFAULT_CONFIG_VALUES.window_threshold_pct,
33
+ action: 'notify',
34
+ },
35
+ models: {
36
+ claude_chain: DEFAULT_CONFIG_VALUES.claude_chain,
37
+ },
38
+ estimation: {
39
+ use_llm_precheck: true,
40
+ llm_precheck_model: DEFAULT_CONFIG_VALUES.llm_precheck_model,
41
+ min_confidence_to_skip_llm: DEFAULT_CONFIG_VALUES.min_confidence_to_skip_llm,
42
+ },
43
+ poll_interval_seconds: DEFAULT_CONFIG_VALUES.poll_interval_seconds,
44
+ ...partial,
45
+ }
46
+ }
@@ -0,0 +1,63 @@
1
+ import { mkdirSync } from 'fs'
2
+ import { dirname } from 'path'
3
+ import type { TaskRecord } from '../types.js'
4
+ import Database from 'better-sqlite3'
5
+
6
+ export type DB = import('better-sqlite3').Database
7
+
8
+ export function initDb(path: string): DB {
9
+ mkdirSync(dirname(path), { recursive: true })
10
+ const db = new Database(path)
11
+
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS tasks (
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ created_at INTEGER NOT NULL,
16
+ repo TEXT,
17
+ prompt_hash TEXT NOT NULL,
18
+ prompt_text TEXT NOT NULL,
19
+ model TEXT NOT NULL,
20
+ complexity TEXT NOT NULL,
21
+ est_layer INTEGER NOT NULL,
22
+ est_cost REAL,
23
+ actual_tokens_in INTEGER,
24
+ actual_tokens_out INTEGER,
25
+ actual_cost REAL,
26
+ window_pct_start REAL,
27
+ window_pct_end REAL,
28
+ model_switched INTEGER DEFAULT 0,
29
+ exit_code INTEGER
30
+ );
31
+ CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC);
32
+ CREATE INDEX IF NOT EXISTS idx_tasks_repo ON tasks(repo);
33
+ CREATE INDEX IF NOT EXISTS idx_tasks_prompt_hash ON tasks(prompt_hash);
34
+ `)
35
+
36
+ return db
37
+ }
38
+
39
+ export function insertTask(db: DB, task: Omit<TaskRecord, 'id'>): number {
40
+ const stmt = db.prepare(`
41
+ INSERT INTO tasks (
42
+ created_at, repo, prompt_hash, prompt_text, model, complexity,
43
+ est_layer, est_cost, actual_tokens_in, actual_tokens_out, actual_cost,
44
+ window_pct_start, window_pct_end, model_switched, exit_code
45
+ ) VALUES (
46
+ @created_at, @repo, @prompt_hash, @prompt_text, @model, @complexity,
47
+ @est_layer, @est_cost, @actual_tokens_in, @actual_tokens_out, @actual_cost,
48
+ @window_pct_start, @window_pct_end, @model_switched, @exit_code
49
+ )
50
+ `)
51
+ const result = stmt.run(task)
52
+ return result.lastInsertRowid as number
53
+ }
54
+
55
+ export function getRecentTasks(db: DB, limit: number): TaskRecord[] {
56
+ return db.prepare(
57
+ 'SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?'
58
+ ).all(limit) as TaskRecord[]
59
+ }
60
+
61
+ export async function openDb(path: string): Promise<DB> {
62
+ return initDb(path)
63
+ }
@@ -0,0 +1,7 @@
1
+ import type { ModelPricing } from '../types.js'
2
+
3
+ export function calculateCost(tokens: { input: number; output: number }, pricing: ModelPricing): number {
4
+ const inputCost = (tokens.input / 1_000_000) * pricing.input_per_million
5
+ const outputCost = (tokens.output / 1_000_000) * pricing.output_per_million
6
+ return inputCost + outputCost
7
+ }
@@ -0,0 +1,57 @@
1
+ import { appendFile, mkdir } from 'fs/promises'
2
+ import { dirname } from 'path'
3
+ import { ERRORS_LOG_PATH, CLAUDE_BOOTSTRAP_API, CLAUDE_USAGE_API } from '../constants.js'
4
+ import type { PlanUsage } from '../types.js'
5
+ import type { ClaudeCredentials } from '../auth/credentials.js'
6
+
7
+ async function logError(message: string): Promise<void> {
8
+ await mkdir(dirname(ERRORS_LOG_PATH), { recursive: true })
9
+ await appendFile(ERRORS_LOG_PATH, `[${new Date().toISOString()}] ${message}\n`)
10
+ }
11
+
12
+ export async function resolveOrgId(creds: ClaudeCredentials): Promise<string | null> {
13
+ try {
14
+ const res = await fetch(CLAUDE_BOOTSTRAP_API, {
15
+ headers: { Authorization: `Bearer ${creds.accessToken}` }
16
+ })
17
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
18
+ const data = await res.json() as { account?: { memberships?: Array<{ organization?: { id: string } }> } }
19
+ return data?.account?.memberships?.[0]?.organization?.id ?? null
20
+ } catch (err) {
21
+ await logError(`bootstrap failed: ${err}`)
22
+ return null
23
+ }
24
+ }
25
+
26
+ export async function fetchPlanUsage(orgId: string, creds: ClaudeCredentials): Promise<PlanUsage | null> {
27
+ try {
28
+ const res = await fetch(`${CLAUDE_USAGE_API}/${orgId}/usage`, {
29
+ headers: { Authorization: `Bearer ${creds.accessToken}` }
30
+ })
31
+ if (!res.ok) {
32
+ await logError(`usage API returned ${res.status}`)
33
+ return null
34
+ }
35
+ const data = await res.json() as {
36
+ five_hour?: { utilization_pct: number; reset_at: string }
37
+ seven_day?: { utilization_pct: number }
38
+ }
39
+ return {
40
+ five_hour_pct: data.five_hour?.utilization_pct ?? 0,
41
+ five_hour_reset_at: data.five_hour?.reset_at ?? '',
42
+ seven_day_pct: data.seven_day?.utilization_pct ?? 0,
43
+ fetched_at: Date.now(),
44
+ }
45
+ } catch (err) {
46
+ await logError(`fetchPlanUsage failed: ${err}`)
47
+ return null
48
+ }
49
+ }
50
+
51
+ export function formatResetCountdown(resetAt: string): string {
52
+ const ms = new Date(resetAt).getTime() - Date.now()
53
+ if (ms <= 0) return 'resetting...'
54
+ const h = Math.floor(ms / 3_600_000)
55
+ const m = Math.floor((ms % 3_600_000) / 60_000)
56
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
57
+ }
@@ -0,0 +1,16 @@
1
+ export interface TokenCounts { input: number; output: number }
2
+
3
+ const TOKEN_PATTERN = /tokens?:\s*([\d,]+)\s*input,\s*([\d,]+)\s*output/i
4
+
5
+ export function parseTokensFromOutput(output: string): TokenCounts | null {
6
+ const match = TOKEN_PATTERN.exec(output)
7
+ if (!match) return null
8
+ return {
9
+ input: parseInt(match[1].replace(/,/g, ''), 10),
10
+ output: parseInt(match[2].replace(/,/g, ''), 10),
11
+ }
12
+ }
13
+
14
+ export function estimateInputTokens(charCount: number): number {
15
+ return Math.ceil(charCount / 4)
16
+ }
package/src/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ export type UserMode = 'api' | 'plan'
2
+ export type Complexity = 'low' | 'medium' | 'heavy' | 'critical'
3
+ export type EstimationLayer = 1 | 2 | 3
4
+
5
+ export interface MeterConfig {
6
+ version: number
7
+ mode: UserMode
8
+ resolved_binaries: { claude: string }
9
+ org_id?: string
10
+ budget: {
11
+ per_task_usd: number
12
+ threshold_pct: number
13
+ action: 'notify'
14
+ }
15
+ plan: {
16
+ window_threshold_pct: number
17
+ action: 'notify'
18
+ }
19
+ models: {
20
+ claude_chain: string[]
21
+ }
22
+ estimation: {
23
+ use_llm_precheck: boolean
24
+ llm_precheck_model: string
25
+ min_confidence_to_skip_llm: number
26
+ }
27
+ poll_interval_seconds: number
28
+ }
29
+
30
+ export interface TaskRecord {
31
+ id?: number
32
+ created_at: number
33
+ repo: string | null
34
+ prompt_hash: string
35
+ prompt_text: string
36
+ model: string
37
+ complexity: Complexity
38
+ est_layer: EstimationLayer
39
+ est_cost: number | null
40
+ actual_tokens_in: number | null
41
+ actual_tokens_out: number | null
42
+ actual_cost: number | null
43
+ window_pct_start: number | null
44
+ window_pct_end: number | null
45
+ model_switched: number
46
+ exit_code: number | null
47
+ }
48
+
49
+ export interface EstimationResult {
50
+ complexity: Complexity
51
+ confidence: number
52
+ estimated_cost: number | null
53
+ layer_used: EstimationLayer
54
+ }
55
+
56
+ export interface PlanUsage {
57
+ five_hour_pct: number
58
+ five_hour_reset_at: string
59
+ seven_day_pct: number
60
+ fetched_at: number
61
+ }
62
+
63
+ export interface ModelPricing {
64
+ model: string
65
+ input_per_million: number
66
+ output_per_million: number
67
+ updated_at: number
68
+ }
69
+
70
+ export interface TokenCounts {
71
+ input: number
72
+ output: number
73
+ }
@@ -0,0 +1,27 @@
1
+ export type KeypressAction = 's' | 'd' | 'c'
2
+
3
+ export function waitForKeypress(
4
+ validKeys: KeypressAction[],
5
+ timeoutMs: number
6
+ ): Promise<KeypressAction | 'timeout'> {
7
+ return new Promise(resolve => {
8
+ const cleanup = () => {
9
+ process.stdin.removeListener('data', onData)
10
+ clearTimeout(timer)
11
+ }
12
+
13
+ const timer = timeoutMs > 0
14
+ ? setTimeout(() => { cleanup(); resolve('timeout') }, timeoutMs)
15
+ : undefined
16
+
17
+ const onData = (data: Buffer) => {
18
+ const key = data.toString().toLowerCase().trim() as KeypressAction
19
+ if (validKeys.includes(key)) {
20
+ cleanup()
21
+ resolve(key)
22
+ }
23
+ }
24
+
25
+ process.stdin.on('data', onData)
26
+ })
27
+ }
@@ -0,0 +1,31 @@
1
+ import type { UserMode } from '../types.js'
2
+
3
+ export interface NotificationState {
4
+ mode: UserMode
5
+ thresholdPct: number
6
+ elapsedCost: number | null
7
+ budgetUsd: number | null
8
+ windowPct: number | null
9
+ nextModel: string | null
10
+ }
11
+
12
+ const YELLOW = '\x1b[33m'
13
+ const BOLD = '\x1b[1m'
14
+ const RESET = '\x1b[0m'
15
+ const DIM = '\x1b[2m'
16
+
17
+ export function renderNotification(state: NotificationState): string {
18
+ let message: string
19
+
20
+ if (state.mode === 'api' && state.elapsedCost !== null && state.budgetUsd !== null) {
21
+ message = `${YELLOW}⚠ ${state.thresholdPct}% budget hit${RESET} ($${state.elapsedCost.toFixed(2)} / $${state.budgetUsd.toFixed(2)})`
22
+ } else {
23
+ message = `${YELLOW}⚠ ${state.windowPct}% window used${RESET}`
24
+ }
25
+
26
+ const actions = state.nextModel
27
+ ? ` ${BOLD}[s]${RESET}${DIM}witch to ${state.nextModel}${RESET} ${BOLD}[d]${RESET}${DIM}ismiss${RESET} ${BOLD}[c]${RESET}${DIM}ancel${RESET}`
28
+ : ` ${BOLD}[d]${RESET}${DIM}ismiss${RESET} ${BOLD}[c]${RESET}${DIM}ancel${RESET}`
29
+
30
+ return message + actions
31
+ }
@@ -0,0 +1,74 @@
1
+ import ansiEscapes from 'ansi-escapes'
2
+ import type { Complexity, UserMode } from '../types.js'
3
+
4
+ export interface StatusBarState {
5
+ model: string
6
+ estimatedCost: number | null
7
+ complexity: Complexity | null
8
+ mode: UserMode
9
+ elapsedCost: number | null
10
+ budgetUsd: number | null
11
+ windowPct: number | null
12
+ windowResetIn: string | null
13
+ }
14
+
15
+ const RESET = '\x1b[0m'
16
+ const DIM = '\x1b[2m'
17
+ const BOLD = '\x1b[1m'
18
+ const YELLOW = '\x1b[33m'
19
+ const RED = '\x1b[31m'
20
+ const GREEN = '\x1b[32m'
21
+ const CYAN = '\x1b[36m'
22
+
23
+ function progressBar(pct: number, width = 12): string {
24
+ const filled = Math.round((pct / 100) * width)
25
+ const empty = width - filled
26
+ const color = pct >= 80 ? RED : pct >= 60 ? YELLOW : GREEN
27
+ return `${color}${'█'.repeat(filled)}${DIM}${'░'.repeat(empty)}${RESET}`
28
+ }
29
+
30
+ export function renderStatusBar(state: StatusBarState): string {
31
+ const parts: string[] = [`${CYAN}◆ meter${RESET}`, `${BOLD}${state.model}${RESET}`]
32
+
33
+ if (state.estimatedCost !== null) {
34
+ parts.push(`${DIM}~$${state.estimatedCost.toFixed(2)}${RESET}`)
35
+ }
36
+ if (state.complexity) {
37
+ parts.push(`${DIM}${state.complexity}${RESET}`)
38
+ }
39
+
40
+ parts.push('│')
41
+
42
+ if (state.mode === 'api' && state.elapsedCost !== null && state.budgetUsd !== null) {
43
+ const pct = (state.elapsedCost / state.budgetUsd) * 100
44
+ parts.push(progressBar(pct))
45
+ parts.push(`$${state.elapsedCost.toFixed(2)} / $${state.budgetUsd.toFixed(2)}`)
46
+ } else if (state.mode === 'plan' && state.windowPct !== null) {
47
+ parts.push(progressBar(state.windowPct))
48
+ parts.push(`${state.windowPct}% 5hr window`)
49
+ if (state.windowResetIn) parts.push(`${DIM}reset in ${state.windowResetIn}${RESET}`)
50
+ } else {
51
+ parts.push(`${DIM}usage unavailable${RESET}`)
52
+ }
53
+
54
+ return parts.join(' ')
55
+ }
56
+
57
+ export function injectStatusBar(content: string, linesAbove: number): void {
58
+ process.stdout.write(
59
+ ansiEscapes.cursorSavePosition +
60
+ ansiEscapes.cursorUp(linesAbove) +
61
+ ansiEscapes.eraseLine +
62
+ content +
63
+ ansiEscapes.cursorRestorePosition
64
+ )
65
+ }
66
+
67
+ export function clearStatusBar(linesAbove: number): void {
68
+ process.stdout.write(
69
+ ansiEscapes.cursorSavePosition +
70
+ ansiEscapes.cursorUp(linesAbove) +
71
+ ansiEscapes.eraseLine +
72
+ ansiEscapes.cursorRestorePosition
73
+ )
74
+ }