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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- [2026-07-28] the dashboard follows the terminal's own colors
- [2026-07-23] an out-of-date dashboard says restart and refuses changes
- [2026-07-23] claude routing keeps the native login, fixes #15
- [2026-07-22] session reset time on analytics, fixes #9
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ tokenmaxx list | status | refresh | doctor

Env: `TOKENMAXX_HOME`, `TOKENMAXX_PROXY_PORT`, `TOKENMAXX_THEME`.

The dashboard asks your terminal for its colors (OSC 4/10/11) and uses them, so it matches the theme you already run. Terminals that don't answer get a built-in dark or light palette instead. Settings → Display switches between `auto`, `dark` and `light` and stores the choice in `~/.tokenmaxx/preferences.json`. `TOKENMAXX_THEME` pins any of the three for one run and beats the stored choice.

## Intended use

tokenmaxx is for one person with accounts they pay for themselves. No account gets bigger limits, no limit gets bypassed, and your credentials stay between your Keychain and the provider. Don't share accounts, don't pool them, don't resell access. Provider terms change, and it's on you to check that yours allow this kind of switching. The software is provided as is, with no warranty.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.58"
"version": "0.0.59"
}
44 changes: 44 additions & 0 deletions src/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readPreferences, writePreferences } from './preferences.ts'

let home: string
let environment: NodeJS.ProcessEnv

beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'tokenmaxx-preferences-'))
environment = { TOKENMAXX_HOME: home }
})

afterEach(async () => {
await rm(home, { force: true, recursive: true })
})

describe('preferences', () => {
test('defaults to auto before anything is written', async () => {
expect(await readPreferences(environment)).toEqual({ theme: 'auto' })
})

test('round-trips a stored theme', async () => {
await writePreferences({ theme: 'light' }, environment)
expect(await readPreferences(environment)).toEqual({ theme: 'light' })
})

test('falls back to auto when the file is corrupt', async () => {
await writeFile(join(home, 'preferences.json'), '{ not json')
expect(await readPreferences(environment)).toEqual({ theme: 'auto' })
})

test('falls back to auto when the stored theme is not one we know', async () => {
await writeFile(join(home, 'preferences.json'), JSON.stringify({ theme: 'gruvbox' }))
expect(await readPreferences(environment)).toEqual({ theme: 'auto' })
})

test('keeps the file private to the user', async () => {
await writePreferences({ theme: 'dark' }, environment)
const stat = await Bun.file(join(home, 'preferences.json')).stat()
expect(stat.mode & 0o777).toBe(0o600)
})
})
38 changes: 38 additions & 0 deletions src/preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
import { applicationPaths } from './paths.ts'

const PreferencesSchema = z.object({
theme: z.enum(['auto', 'dark', 'light'])
})
export type Preferences = z.infer<typeof PreferencesSchema>

const DEFAULT_PREFERENCES: Preferences = { theme: 'auto' }

function preferencesPath(environment: NodeJS.ProcessEnv): string {
return join(applicationPaths(environment).root, 'preferences.json')
}

// Nothing here reaches the manager, so a missing or hand-mangled file falls back to the defaults
// instead of keeping the dashboard shut.
export async function readPreferences(
environment: NodeJS.ProcessEnv = process.env
): Promise<Preferences> {
try {
const raw = await readFile(preferencesPath(environment), 'utf8')
const parsed = PreferencesSchema.safeParse(JSON.parse(raw))
return parsed.success ? parsed.data : DEFAULT_PREFERENCES
} catch {
return DEFAULT_PREFERENCES
}
}

export async function writePreferences(
preferences: Preferences,
environment: NodeJS.ProcessEnv = process.env
): Promise<void> {
await writeFile(preferencesPath(environment), `${JSON.stringify(preferences, null, '\t')}\n`, {
mode: 0o600
})
}
163 changes: 131 additions & 32 deletions src/tui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
requestResetCredits,
requestSwitch
} from '../ipc.ts'
import { readPreferences, writePreferences } from '../preferences.ts'
import { availableUpdate, installedVersion, VERSION } from '../version.ts'
import { buildScenario } from './fixtures.ts'
import {
Expand All @@ -37,8 +38,11 @@ import {
shortWindow,
type Theme,
type ThemeName,
type ThemePreference,
TIMEFRAMES,
type Timeframe,
themeFromTerminal,
themeOverride,
themes,
throughputColumns
} from './format.ts'
Expand Down Expand Up @@ -85,6 +89,9 @@ interface Ctx {
routing: Record<ProviderId, boolean>
cliPresent: Record<ProviderId, boolean>
switchFlagMs: number
themePreference: ThemePreference
themePinnedByEnvironment: boolean
themeFromTerminalActive: boolean
}

function labelWidth(ctx: Ctx): number {
Expand Down Expand Up @@ -762,12 +769,17 @@ function dwellLabel(milliseconds: number): string {
return minutes === 0 ? 'off' : `${minutes}m`
}

interface SettingRow {
provider: ProviderId
key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window'
windowId?: string
windowLabel?: string
}
type SettingRow =
| {
scope: 'provider'
provider: ProviderId
key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window'
windowId?: string
windowLabel?: string
}
| { scope: 'display'; key: 'theme' }

const THEME_CYCLE: readonly ThemePreference[] = ['auto', 'dark', 'light']

function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): UsageWindow[] {
const seen = new Map<string, UsageWindow>()
Expand All @@ -785,18 +797,49 @@ function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): Usa
}

function buildSettingRows(snapshot: DashboardSnapshot): SettingRow[] {
return providerOrder.flatMap(provider => [
{ key: 'routing' as const, provider },
{ key: 'auto' as const, provider },
{ key: 'threshold' as const, provider },
{ key: 'dwell' as const, provider },
...providerWindows(snapshot, provider).map(window => ({
key: 'window' as const,
provider,
windowId: window.id,
windowLabel: window.label
}))
])
return [
...providerOrder.flatMap(provider => [
{ key: 'routing' as const, provider, scope: 'provider' as const },
{ key: 'auto' as const, provider, scope: 'provider' as const },
{ key: 'threshold' as const, provider, scope: 'provider' as const },
{ key: 'dwell' as const, provider, scope: 'provider' as const },
...providerWindows(snapshot, provider).map(window => ({
key: 'window' as const,
provider,
scope: 'provider' as const,
windowId: window.id,
windowLabel: window.label
}))
]),
{ key: 'theme' as const, scope: 'display' as const }
]
}

type ProviderSettingRow = Extract<SettingRow, { scope: 'provider' }>

interface SettingLine {
isSelected: boolean
label: string
value: string
valueColor: string
hint: string
}

function settingLine(ctx: Ctx, line: SettingLine) {
return Box(
{
backgroundColor: line.isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg),
flexDirection: 'row',
width: '100%'
},
Text({ content: line.isSelected ? ' ▸ ' : ' ', fg: rgb(ctx.theme.accent) }),
Text({
content: pad(line.label, 12),
fg: rgb(line.isSelected ? ctx.theme.fg : ctx.theme.dim)
}),
Text({ attributes: 1, content: pad(line.value, 7), fg: rgb(line.valueColor) }),
Text({ content: pad(line.hint, 40), fg: rgb(ctx.theme.faint) })
)
}

function settingsPanel(
Expand All @@ -808,7 +851,12 @@ function settingsPanel(
) {
const state = snapshot.providers.find(s => s.provider === provider)
const policy = state?.policy
const rows = allRows.map((row, index) => ({ index, row })).filter(e => e.row.provider === provider)
const rows = allRows
.map((row, index) => ({ index, row }))
.filter(
(entry): entry is { index: number; row: ProviderSettingRow } =>
entry.row.scope === 'provider' && entry.row.provider === provider
)
const lines = rows.map(entry => {
const { row } = entry
const isSelected = entry.index === selected
Expand Down Expand Up @@ -865,17 +913,7 @@ function settingsPanel(
? ctx.theme.good
: ctx.theme.dim
: ctx.theme.fg
return Box(
{
backgroundColor: isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg),
flexDirection: 'row',
width: '100%'
},
Text({ content: isSelected ? ' ▸ ' : ' ', fg: rgb(ctx.theme.accent) }),
Text({ content: pad(label, 12), fg: rgb(isSelected ? ctx.theme.fg : ctx.theme.dim) }),
Text({ attributes: 1, content: pad(value, 7), fg: rgb(valueColor) }),
Text({ content: pad(hint, 40), fg: rgb(ctx.theme.faint) })
)
return settingLine(ctx, { hint, isSelected, label, value, valueColor })
})
const routed = ctx.routing[provider]
const auto = policy?.enabled ? `⟳ auto ${policy.thresholdPercent}%` : 'auto off'
Expand All @@ -896,12 +934,41 @@ function settingsPanel(
)
}

function displayPanel(ctx: Ctx, allRows: SettingRow[], selected: number) {
const index = allRows.findIndex(row => row.scope === 'display' && row.key === 'theme')
const pinned = ctx.themePinnedByEnvironment
return Box(
{
border: true,
borderColor: rgb(ctx.theme.border),
borderStyle: 'rounded',
flexDirection: 'column',
flexShrink: 0,
title: ' Display ',
titleColor: rgb(ctx.theme.dim),
width: '100%'
},
settingLine(ctx, {
hint: pinned
? 'pinned by TOKENMAXX_THEME'
: ctx.themeFromTerminalActive
? 'following your terminal colors'
: 'auto follows your terminal colors',
isSelected: index === selected,
label: 'theme',
value: ctx.themePreference,
valueColor: pinned ? ctx.theme.dim : ctx.theme.fg
})
)
}

function settingsBody(ctx: Ctx, snapshot: DashboardSnapshot, rows: SettingRow[], selected: number) {
return column(
ctx,
[
settingsPanel(ctx, snapshot, rows, 'openai', selected),
settingsPanel(ctx, snapshot, rows, 'anthropic', selected)
settingsPanel(ctx, snapshot, rows, 'anthropic', selected),
displayPanel(ctx, rows, selected)
],
78
)
Expand Down Expand Up @@ -1235,8 +1302,21 @@ export async function runTuiDashboard(
: { anthropic: true, openai: true }
const renderer = await createCliRenderer({ exitOnCtrlC: false, targetFps: 30 })
await renderer.waitForThemeMode(400).catch(() => null)
const themeEnvironmentOverride = themeOverride(process.env)
const envFallback: ThemeName = detectThemeName(process.env)
const currentTheme = (): Theme => themes[live ? (renderer.themeMode ?? envFallback) : envFallback]
// Fixtures pin a built-in palette so recorded screenshots do not drift with whatever terminal
// renders them. TOKENMAXX_THEME=auto opts a fixture run back into terminal colors.
let preference: ThemePreference =
themeEnvironmentOverride ?? (live ? (await readPreferences()).theme : envFallback)
// Fetched even when a built-in palette is pinned, so switching back to auto needs no restart.
const terminalTheme =
live || preference === 'auto'
? themeFromTerminal(await renderer.getPalette({ size: 16, timeout: 400 }).catch(() => null))
: null
const currentTheme = (): Theme =>
preference === 'auto'
? (terminalTheme ?? themes[live ? (renderer.themeMode ?? envFallback) : envFallback])
: themes[preference]
let simulatedNow = fixture?.now ?? Date.now()
let analytics =
fixture === undefined
Expand Down Expand Up @@ -1286,6 +1366,9 @@ export async function runTuiDashboard(
rows: process.stdout.rows ?? 24,
switchFlagMs: fixture !== undefined && fixture.timewarp > 0 ? 24 * 60_000 : 120_000,
theme: currentTheme(),
themeFromTerminalActive: preference === 'auto' && terminalTheme !== null,
themePinnedByEnvironment: themeEnvironmentOverride !== null,
themePreference: preference,
tier: tierFor(columns)
},
analytics,
Expand Down Expand Up @@ -1472,6 +1555,22 @@ export async function runTuiDashboard(
if (row === undefined) {
return
}
if (row.scope === 'display') {
if (themeEnvironmentOverride !== null) {
state.note = 'theme is pinned by TOKENMAXX_THEME'
paint()
return
}
const position = THEME_CYCLE.indexOf(preference)
preference = THEME_CYCLE[(position + delta + THEME_CYCLE.length) % THEME_CYCLE.length] ?? 'auto'
state.note = `theme ${preference}`
paint()
void writePreferences({ theme: preference }).catch(() => {
state.note = 'theme not saved; check ~/.tokenmaxx is writable'
paint()
})
return
}
const policy = currentPolicy(row.provider)
if (row.key === 'routing') {
toggleRouting(row.provider)
Expand Down
Loading