From f08f97cd3d1934b18d94d69225e5f666fb3ffdb1 Mon Sep 17 00:00:00 2001 From: BlairWelsh Date: Tue, 14 Apr 2026 14:09:39 +0100 Subject: [PATCH] feat: add interactive currency picker to dashboard Press c in the dashboard to open a searchable currency picker modal. Type to filter, arrow keys to navigate, enter to select, escape to cancel. Shows 24 common currencies with the active one marked, and accepts any valid ISO 4217 code typed directly. Session-only switch -- does not write to config, so users can quickly compare costs in different currencies without changing their default. --- src/currency.ts | 17 ++++++ src/dashboard.tsx | 143 +++++++++++++++++++++++++++++++++++++++++++++- src/menubar.ts | 1 + 3 files changed, 160 insertions(+), 1 deletion(-) diff --git a/src/currency.ts b/src/currency.ts index cfb2780..7a145ec 100644 --- a/src/currency.ts +++ b/src/currency.ts @@ -32,6 +32,8 @@ const FRANKFURTER_URL = 'https://api.frankfurter.app/latest?from=USD&to=' // Overwritten by loadCurrency() if the user has configured a currency. let active: CurrencyState = { code: 'USD', rate: 1, symbol: '$' } +const USD: CurrencyState = { code: 'USD', rate: 1, symbol: '$' } + // --------------------------------------------------------------------------- // Intl-based currency helpers // --------------------------------------------------------------------------- @@ -164,6 +166,21 @@ export function getCurrency(): CurrencyState { return active } +/** + * Switches the active currency at runtime. Used by the dashboard currency picker. + * Fetches the exchange rate (from cache or API) and updates the display currency. + * Does not write to the config file -- session-only unless the caller saves separately. + */ +export async function switchCurrency(code: string): Promise { + if (code === 'USD') { + active = USD + return + } + const rate = await getExchangeRate(code) + const symbol = resolveSymbol(code) + active = { code, rate, symbol } +} + /** Returns a dynamic column header like "Cost (AUD)" for use in CSV/JSON exports. */ export function getCostColumnHeader(): string { return `Cost (${active.code})` diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 4efe0b0..f4e7a11 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -4,6 +4,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types import { formatCost, formatTokens } from './format.js' import { parseAllSessions } from './parser.js' import { loadPricing } from './models.js' +import { switchCurrency, getCurrency, isValidCurrencyCode } from './currency.js' import { providers } from './providers/index.js' type Period = 'today' | 'week' | 'month' | '30days' @@ -411,7 +412,9 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool 3 30 days 4 - month + month + c + currency ({getCurrency().code}) {showProvider && ( <> @@ -424,6 +427,115 @@ function StatusBar({ width, showProvider }: { width: number; showProvider?: bool ) } +// Common currencies shown in the picker. Full list is searchable by typing a code. +const COMMON_CURRENCIES = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'GBP', name: 'British Pound' }, + { code: 'EUR', name: 'Euro' }, + { code: 'AUD', name: 'Australian Dollar' }, + { code: 'CAD', name: 'Canadian Dollar' }, + { code: 'NZD', name: 'New Zealand Dollar' }, + { code: 'JPY', name: 'Japanese Yen' }, + { code: 'CHF', name: 'Swiss Franc' }, + { code: 'INR', name: 'Indian Rupee' }, + { code: 'BRL', name: 'Brazilian Real' }, + { code: 'SEK', name: 'Swedish Krona' }, + { code: 'SGD', name: 'Singapore Dollar' }, + { code: 'HKD', name: 'Hong Kong Dollar' }, + { code: 'KRW', name: 'South Korean Won' }, + { code: 'MXN', name: 'Mexican Peso' }, + { code: 'ZAR', name: 'South African Rand' }, + { code: 'DKK', name: 'Danish Krone' }, + { code: 'NOK', name: 'Norwegian Krone' }, + { code: 'PLN', name: 'Polish Zloty' }, + { code: 'THB', name: 'Thai Baht' }, + { code: 'TWD', name: 'Taiwan Dollar' }, + { code: 'TRY', name: 'Turkish Lira' }, + { code: 'PHP', name: 'Philippine Peso' }, + { code: 'CZK', name: 'Czech Koruna' }, +] + +function CurrencyPicker({ width, onSelect, onCancel }: { + width: number + onSelect: (code: string) => void + onCancel: () => void +}) { + const [search, setSearch] = useState('') + const [cursor, setCursor] = useState(0) + const activeCode = getCurrency().code + + const filtered = search.length > 0 + ? COMMON_CURRENCIES.filter(c => + c.code.toLowerCase().includes(search.toLowerCase()) || + c.name.toLowerCase().includes(search.toLowerCase())) + : COMMON_CURRENCIES + + // Keep cursor in bounds when search narrows the list + const safeCursor = Math.min(cursor, Math.max(filtered.length - 1, 0)) + const maxVisible = 12 + + useInput((input, key) => { + if (key.escape) { onCancel(); return } + if (key.return) { + if (filtered.length > 0) { + onSelect(filtered[safeCursor].code) + } else if (search.length === 3 && isValidCurrencyCode(search.toUpperCase())) { + onSelect(search.toUpperCase()) + } + return + } + if (key.upArrow) { setCursor(Math.max(0, safeCursor - 1)); return } + if (key.downArrow) { setCursor(Math.min(filtered.length - 1, safeCursor + 1)); return } + if (key.backspace || key.delete) { + setSearch(s => s.slice(0, -1)) + setCursor(0) + return + } + if (input && input.length === 1 && /[a-zA-Z]/.test(input)) { + setSearch(s => s + input) + setCursor(0) + } + }) + + // Scroll window around the cursor + const scrollStart = Math.max(0, Math.min(safeCursor - Math.floor(maxVisible / 2), filtered.length - maxVisible)) + const visible = filtered.slice(scrollStart, scrollStart + maxVisible) + + return ( + + Currency + Type to search, arrows to move, enter to select, esc to cancel + + {'> '} + {search.length > 0 ? search.toUpperCase() : ''} + {search.length === 0 ? 'search...' : ''} + + + {visible.map((c, i) => { + const idx = scrollStart + i + const isActive = c.code === activeCode + const isSelected = idx === safeCursor + return ( + + + {isSelected ? '> ' : ' '} + {c.code} {c.name} + {isActive ? ' *' : ''} + + + ) + })} + {filtered.length === 0 && search.length >= 3 && isValidCurrencyCode(search.toUpperCase()) && ( + Press enter to use {search.toUpperCase()} + )} + {filtered.length === 0 && (search.length < 3 || !isValidCurrencyCode(search.toUpperCase())) && ( + No matches + )} + + + ) +} + function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) { if (wide) return {children} return <>{children} @@ -475,6 +587,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider const [period, setPeriod] = useState(initialPeriod) const [projects, setProjects] = useState(initialProjects) const [loading, setLoading] = useState(false) + const [showCurrencyPicker, setShowCurrencyPicker] = useState(false) + const [, forceRender] = useState(0) const [activeProvider, setActiveProvider] = useState(initialProvider) const [detectedProviders, setDetectedProviders] = useState([]) const { dashWidth } = getLayout() @@ -515,11 +629,19 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider }, [period, activeProvider, reloadData]) useInput((input, key) => { + // Don't capture keys while the currency picker is open -- it has its own input handler + if (showCurrencyPicker) return + if (input === 'q') { exit() return } + if (input === 'c') { + setShowCurrencyPicker(true) + return + } + if (input === 'p' && multipleProviders) { const options = ['all', ...detectedProviders] const idx = options.indexOf(activeProvider) @@ -540,6 +662,25 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider else if (input === '4') switchPeriod('month') }) + const handleCurrencySelect = async (code: string) => { + setShowCurrencyPicker(false) + await switchCurrency(code) + forceRender(n => n + 1) + } + + const handleCurrencyCancel = () => { + setShowCurrencyPicker(false) + } + + if (showCurrencyPicker) { + return ( + + + + + ) + } + if (loading) { return ( diff --git a/src/menubar.ts b/src/menubar.ts index 530e39f..503939a 100644 --- a/src/menubar.ts +++ b/src/menubar.ts @@ -174,6 +174,7 @@ export function renderMenubarFormat( { code: 'KRW', name: 'South Korean Won' }, { code: 'MXN', name: 'Mexican Peso' }, { code: 'ZAR', name: 'South African Rand' }, + { code: 'DKK', name: 'Danish Krone' }, ] lines.push(`Currency: ${activeCurrency} | size=14`) for (const { code, name } of currencies) {