diff --git a/CHANGELOG.md b/CHANGELOG.md index b5340fd..3107720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +- [2026-07-22] clipped settings panels stay open + +A panel cut off by scrolling keeps its title but drops its bottom +border, so it reads as continuing into the more line instead of +finished. + +Co-Authored-By: Claude Fable 5 +- [2026-07-22] [settings shows the harnesses and scrolls + +A harnesses box joins settings: green on when routed, warn off with an +enter hint when installed but not routed, grey when the harness is not +on the machine (binary or config detection). Enter installs or removes +the providers inline — no CLI round-trip. The settings tab now windows +its rows to the terminal height with more indicators, and the 2s +background tick no longer holds the busy flag that was eating +keypresses. + +Co-Authored-By: Claude Fable 5 ](https://github.com/RubricLab/tokenmaxx/commit/89ba154b91d73db4059f9a44704633141497e0a0) +- [2026-07-22] [the proxy lifts system prompts for the chatgpt backend + +Third-party harnesses send standard Responses requests: system messages +in the input array and max_output_tokens set. The ChatGPT codex backend +rejects both. The proxy now adapts oauth-bound openai requests (system +and developer messages move into instructions, max_output_tokens drops) +so openclaw, pi, and hermes work without harness-specific dialects. +Also ships only gpt-5.6-sol — the one model the backend accepts. + +Co-Authored-By: Claude Fable 5 ](https://github.com/RubricLab/tokenmaxx/commit/b8462fc888c18c3495c913cfb6376d4f865b221b) +- [2026-07-22] install into openclaw, pi, and hermes, fixes #11 - [2026-07-22] session reset time on analytics, fixes #9 - [2026-07-22] logout - [2026-07-22] api keys and extra usage, fixes #10 diff --git a/package.json b/package.json index 501b45d..eb753a1 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.56" + "version": "0.0.60" } diff --git a/src/cli.ts b/src/cli.ts index 097d647..8adb9e8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,11 +8,15 @@ import { z } from 'zod' import { registerClaudeAccount, registerClaudeApiKeyAccount } from './claude.ts' import { registerCodexAccount, registerOpenAiApiKeyAccount } from './codex.ts' import { + type HarnessTarget, + harnessStatus, installClaudeConfig, installCodexConfig, + installHarnessConfig, installStatus, uninstallClaudeConfig, - uninstallCodexConfig + uninstallCodexConfig, + uninstallHarnessConfig } from './config-install.ts' import type { Account, ProviderId } from './domain.ts' import { ApplicationError, errorMessage } from './errors.ts' @@ -240,7 +244,7 @@ function help(): string { 'sign in an account · re-run to re-auth', 'add --api-key to use an API key instead' ), - row('install', 'route codex & claude through tokenmaxx'), + row('install [openclaw|pi|hermes]', 'route codex & claude, or a harness'), row('uninstall', 'restore your original config'), '', head('Everyday'), @@ -771,8 +775,33 @@ async function configureAutomation( } } -async function installConfig(context: ApplicationContext): Promise { +const harnessTargets = new Set(['openclaw', 'pi', 'hermes']) + +function harnessTarget(value: string | undefined): HarnessTarget | null { + return value !== undefined && harnessTargets.has(value as HarnessTarget) + ? (value as HarnessTarget) + : null +} + +async function installConfig(context: ApplicationContext, targetArgument?: string): Promise { + const target = harnessTarget(targetArgument) + if (targetArgument !== undefined && target === null) { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx install [openclaw|pi|hermes]') + } await ensureDaemon(context) + if (target !== null) { + const result = await installHarnessConfig(target, context.paths) + if (!result.applied) { + process.stdout.write(`Left ${result.path} alone: ${result.manual}\n`) + return + } + process.stdout.write( + `${target} now has tokenmaxx-anthropic and tokenmaxx-openai providers (${result.path}).\n` + + `Pick a tokenmaxx model inside ${target} and requests route through the proxy.\n` + + `Undo any time with: tokenmaxx uninstall ${target}\n` + ) + return + } await installCodexConfig(context.paths) await installClaudeConfig(context.paths) process.stdout.write( @@ -782,7 +811,20 @@ async function installConfig(context: ApplicationContext): Promise { ) } -async function uninstallConfig(): Promise { +async function uninstallConfig(targetArgument?: string): Promise { + const target = harnessTarget(targetArgument) + if (targetArgument !== undefined && target === null) { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [openclaw|pi|hermes]') + } + if (target !== null) { + const result = await uninstallHarnessConfig(target) + process.stdout.write( + result.applied + ? `Removed the tokenmaxx providers from ${result.path}.\n` + : (result.manual ?? `${target} was not routed; nothing to restore.`) + '\n' + ) + return + } const codex = await uninstallCodexConfig() const claude = await uninstallClaudeConfig() if (codex === null && claude === null) { @@ -864,6 +906,18 @@ async function doctor(context: ApplicationContext): Promise { : 'not routed — run tokenmaxx install' }\n` ) + for (const harness of await harnessStatus()) { + if (!harness.present) { + continue + } + process.stdout.write( + `${harness.routed ? 'ok ' : 'note '} ${harness.target.padEnd(8)} ${ + harness.routed + ? 'has the tokenmaxx providers' + : `not routed — run tokenmaxx install ${harness.target}` + }\n` + ) + } process.stdout.write(`state ${context.paths.database}\n`) const legacyDirectories = [join(context.paths.root, 'codex'), join(context.paths.root, 'claude')] const legacyDetected = await Promise.all( @@ -1005,10 +1059,10 @@ export async function runCli(rawArguments: readonly string[]): Promise { listAccounts(context) return 0 case 'install': - await installConfig(context) + await installConfig(context, arguments_[1]) return 0 case 'uninstall': - await uninstallConfig() + await uninstallConfig(arguments_[1]) return 0 case 'daemon': switch (arguments_[1]) { diff --git a/src/codex.ts b/src/codex.ts index 0772e08..8edc6e2 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -359,6 +359,7 @@ export async function codexUpstream(input: { return { accountId: input.account.id, baseUrl: upstreamFor('openai'), + dialect: 'chatgpt', headers: { authorization: `Bearer ${auth.tokens.access_token}`, 'chatgpt-account-id': codexIdentity(auth).accountId diff --git a/src/config-install.test.ts b/src/config-install.test.ts index c73e716..716b75e 100644 --- a/src/config-install.test.ts +++ b/src/config-install.test.ts @@ -3,7 +3,13 @@ import { mkdtempSync, rmSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { installCodexConfig, installStatus, uninstallCodexConfig } from './config-install.ts' +import { + installCodexConfig, + installHarnessConfig, + installStatus, + uninstallCodexConfig, + uninstallHarnessConfig +} from './config-install.ts' import { applicationPaths } from './paths.ts' const legacyBrokenConfig = `model = "gpt-5.6-sol" @@ -109,3 +115,67 @@ describe('installCodexConfig', () => { expect(parsed.model_provider).toBe('tokenmaxx') }) }) + +describe('harness installs', () => { + test('openclaw providers merge in and back out without touching the rest', async () => { + process.env.OPENCLAW_CONFIG_PATH = join(home, 'openclaw.json') + await writeFile( + process.env.OPENCLAW_CONFIG_PATH, + JSON.stringify({ agents: { defaults: { model: { primary: 'anthropic/claude-opus-4-8' } } } }) + ) + const installed = await installHarnessConfig('openclaw', applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(installed.path, 'utf8')) + expect(config.models.providers['tokenmaxx-anthropic'].api).toBe('anthropic-messages') + expect(config.models.providers['tokenmaxx-openai'].baseUrl).toContain('/openai') + expect(config.agents.defaults.model.primary).toBe('anthropic/claude-opus-4-8') + const removed = await uninstallHarnessConfig('openclaw') + expect(removed.applied).toBe(true) + const restored = JSON.parse(await readFile(installed.path, 'utf8')) + expect(restored.models.providers['tokenmaxx-anthropic']).toBeUndefined() + delete process.env.OPENCLAW_CONFIG_PATH + }) + + test('a json5 openclaw config is left alone with manual instructions', async () => { + process.env.OPENCLAW_CONFIG_PATH = join(home, 'openclaw.json') + await writeFile(process.env.OPENCLAW_CONFIG_PATH, '{\n // my settings\n models: {},\n}\n') + const result = await installHarnessConfig('openclaw', applicationPaths()) + expect(result.applied).toBe(false) + expect(result.manual).toContain('models.providers') + expect(await readFile(result.path, 'utf8')).toContain('// my settings') + delete process.env.OPENCLAW_CONFIG_PATH + }) + + test('pi models.json gains and loses the providers cleanly', async () => { + process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') + const installed = await installHarnessConfig('pi', applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(installed.path, 'utf8')) + expect(config.providers['tokenmaxx-anthropic'].baseUrl).toContain('/anthropic') + const removed = await uninstallHarnessConfig('pi') + expect(removed.applied).toBe(true) + expect(JSON.parse(await readFile(installed.path, 'utf8')).providers).toEqual({}) + delete process.env.PI_CODING_AGENT_DIR + }) + + test('hermes gets a marked block that round-trips, and defers when providers exist', async () => { + process.env.HERMES_HOME = join(home, 'hermes') + await mkdir(process.env.HERMES_HOME, { recursive: true }) + const configPath = join(process.env.HERMES_HOME, 'config.yaml') + await writeFile(configPath, 'model:\n default: "claude-opus-4-8"\n') + const installed = await installHarnessConfig('hermes', applicationPaths()) + expect(installed.applied).toBe(true) + const written = await readFile(configPath, 'utf8') + expect(written).toContain('api_mode: "codex_responses"') + expect(written).toContain('model:') + const removed = await uninstallHarnessConfig('hermes') + expect(removed.applied).toBe(true) + expect(await readFile(configPath, 'utf8')).not.toContain('tokenmaxx-anthropic') + + await writeFile(configPath, 'providers:\n mine:\n base_url: "https://example.com"\n') + const deferred = await installHarnessConfig('hermes', applicationPaths()) + expect(deferred.applied).toBe(false) + expect(await readFile(configPath, 'utf8')).not.toContain('tokenmaxx') + delete process.env.HERMES_HOME + }) +}) diff --git a/src/config-install.ts b/src/config-install.ts index 9339c2d..36151b5 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { ApplicationPaths } from './paths.ts' @@ -191,3 +191,248 @@ export async function installStatus(): Promise { } return { claudeRouted, codexRouted, codexStale } } + +export type HarnessTarget = 'openclaw' | 'pi' | 'hermes' + +export interface HarnessResult { + path: string + applied: boolean + manual: string | null +} + +function openclawConfigPath(): string { + return process.env.OPENCLAW_CONFIG_PATH ?? join(homedir(), '.openclaw', 'openclaw.json') +} + +function piModelsPath(): string { + return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json') +} + +function hermesConfigPath(): string { + return join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'config.yaml') +} + +const anthropicModels = [ + { contextWindow: 200_000, id: 'claude-opus-4-8', maxTokens: 32_000, name: 'Opus via tokenmaxx' }, + { + contextWindow: 200_000, + id: 'claude-sonnet-4-6', + maxTokens: 32_000, + name: 'Sonnet via tokenmaxx' + } +] +// The ChatGPT codex backend accepts exactly this model id for subscription +// accounts (probed 2026-07-22; gpt-5.6-codex and friends are all rejected). +const openaiModels = [ + { contextWindow: 400_000, id: 'gpt-5.6-sol', maxTokens: 128_000, name: 'GPT via tokenmaxx' } +] + +function openclawProviders(paths: ApplicationPaths): Record { + const zeroCost = { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 } + const model = (entry: (typeof anthropicModels)[number]) => ({ + contextWindow: entry.contextWindow, + cost: zeroCost, + id: entry.id, + input: ['text', 'image'], + maxTokens: entry.maxTokens, + name: entry.name, + reasoning: true + }) + return { + 'tokenmaxx-anthropic': { + api: 'anthropic-messages', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'anthropic'), + models: anthropicModels.map(model) + }, + 'tokenmaxx-openai': { + api: 'openai-responses', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'openai'), + models: openaiModels.map(model) + } + } +} + +function piProviders(paths: ApplicationPaths): Record { + return { + 'tokenmaxx-anthropic': { + api: 'anthropic-messages', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'anthropic'), + models: anthropicModels.map(entry => ({ id: entry.id, reasoning: true })) + }, + 'tokenmaxx-openai': { + api: 'openai-responses', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'openai'), + models: openaiModels.map(entry => ({ id: entry.id, reasoning: true })) + } + } +} + +function hermesManagedBlock(paths: ApplicationPaths): string { + return [ + topBeginMarker, + 'providers:', + ' tokenmaxx-anthropic:', + ` base_url: "${proxyBaseUrl(paths, 'anthropic')}"`, + ` api_key: "${dummyAuthToken}"`, + ' api_mode: "anthropic_messages"', + ' tokenmaxx-openai:', + ` base_url: "${proxyBaseUrl(paths, 'openai')}"`, + ` api_key: "${dummyAuthToken}"`, + ' api_mode: "codex_responses"', + topEndMarker + ].join('\n') +} + +function parseJsonObject(raw: string): Record | null { + if (raw.trim().length === 0) { + return {} + } + try { + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +async function writeJsonProviders( + path: string, + providersOf: (config: Record) => Record, + providers: Record | null, + manual: string +): Promise { + const raw = await readFileOrEmpty(path) + const config = parseJsonObject(raw) + if (config === null) { + return { applied: false, manual, path } + } + const bucket = providersOf(config) + for (const key of ['tokenmaxx-anthropic', 'tokenmaxx-openai']) { + delete bucket[key] + } + if (providers !== null) { + Object.assign(bucket, providers) + } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + return { applied: true, manual: null, path } +} + +function ensureObject(parent: Record, key: string): Record { + const value = parent[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + parent[key] = {} + } + return parent[key] as Record +} + +function openclawBucket(config: Record): Record { + return ensureObject(ensureObject(config, 'models'), 'providers') +} + +function piBucket(config: Record): Record { + return ensureObject(config, 'providers') +} + +export async function installHarnessConfig( + target: HarnessTarget, + paths: ApplicationPaths +): Promise { + if (target === 'openclaw') { + return writeJsonProviders( + openclawConfigPath(), + openclawBucket, + openclawProviders(paths), + `could not parse it as JSON (JSON5 comments?) — add this under models.providers yourself:\n${JSON.stringify(openclawProviders(paths), null, 2)}` + ) + } + if (target === 'pi') { + return writeJsonProviders( + piModelsPath(), + piBucket, + piProviders(paths), + `could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}` + ) + } + const path = hermesConfigPath() + const raw = await readFileOrEmpty(path) + const stripped = stripMarkedBlock(raw, topBeginMarker, topEndMarker).trimEnd() + if (/^providers\s*:/m.test(stripped)) { + return { + applied: false, + manual: `it already defines providers, and yaml duplicate keys silently override — merge this into that mapping yourself:\n${hermesManagedBlock(paths)}`, + path + } + } + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + `${stripped.length === 0 ? '' : `${stripped}\n\n`}${hermesManagedBlock(paths)}\n`, + { mode: 0o600 } + ) + return { applied: true, manual: null, path } +} + +export async function uninstallHarnessConfig(target: HarnessTarget): Promise { + if (target === 'openclaw' || target === 'pi') { + const path = target === 'openclaw' ? openclawConfigPath() : piModelsPath() + const raw = await readFile(path, 'utf8').catch(() => null) + if (raw === null) { + return { applied: false, manual: null, path } + } + return writeJsonProviders( + path, + target === 'openclaw' ? openclawBucket : piBucket, + null, + 'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself' + ) + } + const path = hermesConfigPath() + const raw = await readFile(path, 'utf8').catch(() => null) + if (raw === null || !raw.includes(topBeginMarker)) { + return { applied: false, manual: null, path } + } + await writeFile(path, `${stripMarkedBlock(raw, topBeginMarker, topEndMarker).trim()}\n`, { + mode: 0o600 + }) + return { applied: true, manual: null, path } +} + +export interface HarnessStatus { + target: HarnessTarget + present: boolean + routed: boolean +} + +// A harness counts as present when its binary is on PATH or its config +// exists — someone who installed openclaw but never launched it has the +// binary and no config dir. +export async function harnessStatus( + which: (binary: string) => string | null = Bun.which +): Promise { + const entries: { target: HarnessTarget; path: string }[] = [ + { path: openclawConfigPath(), target: 'openclaw' }, + { path: piModelsPath(), target: 'pi' }, + { path: hermesConfigPath(), target: 'hermes' } + ] + return Promise.all( + entries.map(async ({ target, path }) => { + const raw = await readFile(path, 'utf8').catch(() => null) + const configDir = dirname(target === 'pi' ? dirname(path) : path) + const present = + raw !== null || + which(target) !== null || + (await stat(configDir).then( + () => true, + () => false + )) + return { present, routed: raw?.includes('tokenmaxx-anthropic') ?? false, target } + }) + ) +} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 5189685..189bebe 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,5 +1,52 @@ import { describe, expect, test } from 'bun:test' -import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' +import { adaptChatGptRequest, createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' + +describe('chatgpt dialect adapter', () => { + test('lifts system messages into instructions and drops max_output_tokens', () => { + const adapted = JSON.parse( + adaptChatGptRequest( + JSON.stringify({ + input: [ + { content: [{ text: 'You are a helpful agent.', type: 'input_text' }], role: 'system' }, + { content: [{ text: 'hi', type: 'input_text' }], role: 'user' } + ], + max_output_tokens: 4096, + model: 'gpt-5.6-sol', + store: false, + stream: true + }) + ) + ) + expect(adapted.instructions).toBe('You are a helpful agent.') + expect(adapted.input).toHaveLength(1) + expect(adapted.input[0].role).toBe('user') + expect(adapted.max_output_tokens).toBeUndefined() + }) + + test('merges lifted developer messages after existing instructions', () => { + const adapted = JSON.parse( + adaptChatGptRequest( + JSON.stringify({ + input: [ + { content: [{ text: 'Prefer short replies.', type: 'input_text' }], role: 'developer' } + ], + instructions: 'You are a coding agent.' + }) + ) + ) + expect(adapted.instructions).toBe('You are a coding agent.\n\nPrefer short replies.') + expect(adapted.input).toHaveLength(0) + }) + + test('leaves codex-shaped requests and non-json bodies alone', () => { + const codexShaped = JSON.stringify({ + input: [{ content: [{ text: 'hi', type: 'input_text' }], role: 'user' }], + instructions: 'You are Codex.' + }) + expect(JSON.parse(adaptChatGptRequest(codexShaped))).toEqual(JSON.parse(codexShaped)) + expect(adaptChatGptRequest('not json')).toBe('not json') + }) +}) type Observed = { model: string | null diff --git a/src/proxy.ts b/src/proxy.ts index 88a7469..35b86ab 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,7 @@ export interface UpstreamInjection { headers: Record appendHeaders?: Record stripHeaders?: readonly string[] + dialect?: 'chatgpt' } interface ProxyCredentialSource { @@ -200,6 +201,50 @@ export async function proxyIdentity(port: number): Promise<'tokenmaxx' | 'foreig } } +interface ResponsesInputMessage { + role?: string + content?: { type?: string; text?: string }[] +} + +function messageText(item: ResponsesInputMessage): string { + return (Array.isArray(item.content) ? item.content : []) + .map(part => part.text ?? '') + .filter(text => text.length > 0) + .join('\n') +} + +// The ChatGPT codex backend rejects requests third-party harnesses send to a +// standard Responses endpoint: system messages must ride in `instructions` +// ("System messages are not allowed") and `max_output_tokens` is unsupported. +export function adaptChatGptRequest(raw: string): string { + let parsed: { input?: unknown; instructions?: unknown; [key: string]: unknown } + try { + parsed = JSON.parse(raw) as typeof parsed + } catch { + return raw + } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.input)) { + return raw + } + const isSystem = (item: unknown): item is ResponsesInputMessage => { + const role = (item as ResponsesInputMessage | null)?.role + return role === 'system' || role === 'developer' + } + const lifted = parsed.input.filter(isSystem).map(messageText) + const instructions = [ + ...(typeof parsed.instructions === 'string' ? [parsed.instructions] : []), + ...lifted + ] + .filter(text => text.length > 0) + .join('\n\n') + const { max_output_tokens: _dropped, ...rest } = parsed + return JSON.stringify({ + ...rest, + input: parsed.input.filter(item => !isSystem(item)), + ...(instructions.length > 0 ? { instructions } : {}) + }) +} + const strippedRequestHeaders = [ 'host', 'connection', @@ -288,7 +333,10 @@ function createProxyHandler(options: ProxyOptions): ProxyHandler { request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer() const send = (injection: UpstreamInjection): Promise => doFetch(`${injection.baseUrl.replace(/\/$/, '')}${route.rest}${url.search}`, { - body, + body: + injection.dialect === 'chatgpt' && body !== undefined + ? adaptChatGptRequest(new TextDecoder().decode(body)) + : body, headers: forwardHeaders(request.headers, injection), method: request.method, redirect: 'manual', diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 9f4adc1..3ae0fbe 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -1,4 +1,18 @@ -import { Box, createCliRenderer, parseColor, type RGBA, Text } from '@opentui/core' +import { + type BorderSides, + Box, + createCliRenderer, + parseColor, + type RGBA, + Text +} from '@opentui/core' +import { + type HarnessStatus, + type HarnessTarget, + harnessStatus, + installHarnessConfig, + uninstallHarnessConfig +} from '../config-install.ts' import type { Account, AnalyticsSnapshot, @@ -20,6 +34,7 @@ import { requestResetCredits, requestSwitch } from '../ipc.ts' +import { applicationPaths } from '../paths.ts' import { availableUpdate } from '../version.ts' import { buildScenario } from './fixtures.ts' import { @@ -762,12 +777,14 @@ 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 = + | { + provider: ProviderId + key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window' + windowId?: string + windowLabel?: string + } + | { key: 'harness'; provider?: undefined; target: HarnessTarget } function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): UsageWindow[] { const seen = new Map() @@ -784,31 +801,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 - })) - ]) +function buildSettingRows(snapshot: DashboardSnapshot, harnesses: HarnessStatus[]): 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 + })) + ]), + ...harnesses.map(status => ({ key: 'harness' as const, target: status.target })) + ] } +// A panel clipped by scrolling keeps its title but loses its bottom border, +// so it reads as continuing into the "↓ more" line instead of complete. +const openBottomBorder: BorderSides[] = ['top', 'left', 'right'] + function settingsPanel( ctx: Ctx, snapshot: DashboardSnapshot, allRows: SettingRow[], provider: ProviderId, - selected: number + selected: number, + visible: Set, + openBottom: boolean ) { 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 })) + .flatMap(e => + e.row.key !== 'harness' && e.row.provider === provider && visible.has(e.index) + ? [{ index: e.index, row: e.row }] + : [] + ) + if (rows.length === 0) { + return null + } const lines = rows.map(entry => { const { row } = entry const isSelected = entry.index === selected @@ -881,7 +916,7 @@ function settingsPanel( const auto = policy?.enabled ? `⟳ auto ${policy.thresholdPercent}%` : 'auto off' return Box( { - border: true, + border: openBottom ? openBottomBorder : true, borderColor: rgb(routed ? ctx.theme.border : ctx.theme.warn), borderStyle: 'rounded', flexDirection: 'column', @@ -896,17 +931,138 @@ function settingsPanel( ) } -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) - ], - 78 +function harnessPanel( + ctx: Ctx, + statuses: HarnessStatus[], + allRows: SettingRow[], + selected: number, + visible: Set, + openBottom: boolean +) { + const entries = allRows + .map((row, index) => ({ index, row })) + .flatMap(e => + e.row.key === 'harness' && visible.has(e.index) ? [{ index: e.index, target: e.row.target }] : [] + ) + if (entries.length === 0) { + return null + } + const lines = entries.map(entry => { + const status = statuses.find(s => s.target === entry.target) + const isSelected = entry.index === selected + const present = status?.present ?? false + const routed = status?.routed ?? false + const value = routed ? 'on' : present ? 'off' : '—' + const hint = routed + ? 'requests route through tokenmaxx' + : present + ? '⏎ routes it through tokenmaxx' + : 'not installed' + const valueColor = routed ? ctx.theme.good : present ? ctx.theme.warn : ctx.theme.faint + 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(entry.target, 12), + fg: rgb(present ? (isSelected ? ctx.theme.fg : ctx.theme.dim) : ctx.theme.faint) + }), + Text({ attributes: 1, content: pad(value, 7), fg: rgb(valueColor) }), + Text({ content: pad(hint, 40), fg: rgb(ctx.theme.faint) }) + ) + }) + return Box( + { + border: openBottom ? openBottomBorder : true, + borderColor: rgb(ctx.theme.border), + borderStyle: 'rounded', + flexDirection: 'column', + flexShrink: 0, + title: ' harnesses ', + titleColor: rgb(ctx.theme.dim), + width: '100%' + }, + ...lines ) } +function settingsBody( + ctx: Ctx, + snapshot: DashboardSnapshot, + harnesses: HarnessStatus[], + rows: SettingRow[], + state: ViewState, + budget: number +) { + const total = rows.length + const openaiCount = rows.filter(r => r.key !== 'harness' && r.provider === 'openai').length + const anthropicCount = rows.filter(r => r.key !== 'harness' && r.provider === 'anthropic').length + const panelOf = (index: number): number => + index < openaiCount ? 0 : index < openaiCount + anthropicCount ? 1 : 2 + // Rows cost one line each; entering a new panel adds its borders (and the + // column gap after the previous panel). Four lines stay reserved for the + // scroll indicators and their gaps. + const lastVisible = (scroll: number): number => { + let used = 4 + let last = scroll + let lastPanel = -1 + for (let index = scroll; index < total; index += 1) { + const cost = 1 + (panelOf(index) === lastPanel ? 0 : lastPanel === -1 ? 2 : 3) + if (used + cost > budget && index > scroll) { + break + } + used += cost + lastPanel = panelOf(index) + last = index + } + return last + } + state.settingsScroll = Math.max(0, Math.min(state.settingsScroll, total - 1)) + if (state.settingsSelected < state.settingsScroll) { + state.settingsScroll = state.settingsSelected + } + while ( + state.settingsScroll < state.settingsSelected && + lastVisible(state.settingsScroll) < state.settingsSelected + ) { + state.settingsScroll += 1 + } + const start = state.settingsScroll + const end = lastVisible(start) + const visible = new Set() + for (let index = start; index <= end; index += 1) { + visible.add(index) + } + const clippedBelow = (lastRow: number): boolean => end < lastRow + const children = [ + ...(start > 0 ? [centered(Text({ content: '↑ more', fg: rgb(ctx.theme.faint) }))] : []), + settingsPanel( + ctx, + snapshot, + rows, + 'openai', + state.settingsSelected, + visible, + clippedBelow(openaiCount - 1) + ), + settingsPanel( + ctx, + snapshot, + rows, + 'anthropic', + state.settingsSelected, + visible, + clippedBelow(openaiCount + anthropicCount - 1) + ), + harnessPanel(ctx, harnesses, rows, state.settingsSelected, visible, clippedBelow(total - 1)), + ...(end < total - 1 ? [centered(Text({ content: '↓ more', fg: rgb(ctx.theme.faint) }))] : []) + ].filter(child => child !== null) + return column(ctx, children, 78) +} + function accountsBody(ctx: Ctx, snapshot: DashboardSnapshot, rows: Row[], selected: number) { const note = legend(ctx, snapshot) const width = accountsWidth(ctx, snapshot) @@ -1066,6 +1222,7 @@ interface ViewState { tab: Tab selected: number settingsSelected: number + settingsScroll: number timeframeIndex: number analyticsView: AnalyticsView modelScroll: number @@ -1084,7 +1241,13 @@ type DashboardAction = | { kind: 'routing'; provider: ProviderId; enable: boolean } | { kind: 'update'; version: string } -function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewState) { +function view( + ctx: Ctx, + analytics: AnalyticsSnapshot, + rows: Row[], + harnesses: HarnessStatus[], + state: ViewState +) { const clock = new Date(ctx.now).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) const freshestMillis = analytics.snapshot.usage .map(u => Date.parse(u.observedAt)) @@ -1173,8 +1336,16 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt : settingsBody( ctx, analytics.snapshot, - buildSettingRows(analytics.snapshot), - state.settingsSelected + harnesses, + buildSettingRows(analytics.snapshot, harnesses), + state, + Math.max( + 6, + ctx.rows - + 8 - + (state.alert === '' ? 0 : 2) - + (state.updateAvailable !== null && !state.updateDismissed ? 2 : 0) + ) ) ) children.push(Box({ flexGrow: 1 })) @@ -1228,6 +1399,13 @@ export async function runTuiDashboard( ? await readAnalytics(socketPath) : buildScenario(fixture.name, simulatedNow) let rows = orderedRows(analytics.snapshot) + let harnesses: HarnessStatus[] = live + ? await harnessStatus() + : [ + { present: true, routed: true, target: 'openclaw' }, + { present: true, routed: false, target: 'pi' }, + { present: false, routed: false, target: 'hermes' } + ] const state: ViewState = { addConfirm: null, alert: options.alert ?? '', @@ -1236,6 +1414,7 @@ export async function runTuiDashboard( note: '', resetConfirm: null, selected: 0, + settingsScroll: 0, settingsSelected: 0, tab: 'accounts', timeframeIndex: 2, @@ -1274,6 +1453,7 @@ export async function runTuiDashboard( }, analytics, rows, + harnesses, state ) } catch { @@ -1304,16 +1484,30 @@ export async function runTuiDashboard( } } - const reload = (refresh: boolean) => - withBusy(refresh ? 'refreshing…' : '', async () => { - if (refresh) { - await refreshUsage(socketPath) - } + const reload = () => + withBusy('refreshing…', async () => { + await refreshUsage(socketPath) analytics = await readAnalytics(socketPath) rows = orderedRows(analytics.snapshot) + harnesses = await harnessStatus() clampSelection() }) + // The background tick must never hold `busy` — a keypress landing during a + // held tick would be silently dropped. + const quietReload = async () => { + if (busy) { + return + } + try { + analytics = await readAnalytics(socketPath) + rows = orderedRows(analytics.snapshot) + harnesses = await harnessStatus() + clampSelection() + paint() + } catch {} + } + const needsLogin = (accountId: string): boolean => { const account = analytics.snapshot.accounts.find(a => a.id === accountId) return ( @@ -1452,10 +1646,31 @@ export async function runTuiDashboard( } const adjustSetting = (delta: number) => { - const row = buildSettingRows(analytics.snapshot)[state.settingsSelected] + const row = buildSettingRows(analytics.snapshot, harnesses)[state.settingsSelected] if (row === undefined) { return } + if (row.key === 'harness') { + const status = harnesses.find(s => s.target === row.target) + if (status === undefined || !status.present) { + return + } + void withBusy( + status.routed ? `unrouting ${row.target}…` : `routing ${row.target}…`, + async () => { + if (status.routed) { + await uninstallHarnessConfig(row.target) + } else { + const result = await installHarnessConfig(row.target, applicationPaths()) + if (result.manual !== null) { + throw new Error(`${row.target} needs a manual edit — run: tokenmaxx install ${row.target}`) + } + } + harnesses = await harnessStatus() + } + ) + return + } const policy = currentPolicy(row.provider) if (row.key === 'routing') { toggleRouting(row.provider) @@ -1486,7 +1701,7 @@ export async function runTuiDashboard( await new Promise(resolve => { const tick = 250 const interval = live - ? setInterval(() => void reload(false).catch(() => undefined), 2_000) + ? setInterval(() => void quietReload(), 2_000) : fixture.timewarp > 0 ? setInterval(() => { simulatedNow += tick * fixture.timewarp @@ -1589,7 +1804,7 @@ export async function runTuiDashboard( paint() } else if (key.name === 'r') { if (!(state.tab === 'accounts' && openResetConfirm()) && live) { - void reload(true) + void reload() } } else if (state.tab === 'analytics') { if (key.name === 'left' || key.name === 'h') { @@ -1612,7 +1827,7 @@ export async function runTuiDashboard( } } } else if (state.tab === 'settings') { - const settingCount = buildSettingRows(analytics.snapshot).length + const settingCount = buildSettingRows(analytics.snapshot, harnesses).length if (key.name === 'up' || key.name === 'k') { state.settingsSelected = Math.max(0, state.settingsSelected - 1) paint()