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.
This commit is contained in:
BlairWelsh 2026-04-14 14:09:39 +01:00
parent d322d9b837
commit f08f97cd3d
3 changed files with 160 additions and 1 deletions

View file

@ -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<void> {
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})`

View file

@ -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
<Text color={ORANGE} bold>3</Text>
<Text dimColor> 30 days </Text>
<Text color={ORANGE} bold>4</Text>
<Text dimColor> month</Text>
<Text dimColor> month </Text>
<Text color={ORANGE} bold>c</Text>
<Text dimColor> currency ({getCurrency().code})</Text>
{showProvider && (
<>
<Text dimColor> </Text>
@ -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 (
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} width={Math.min(width, 44)} paddingX={1}>
<Text bold color={ORANGE}>Currency</Text>
<Text dimColor>Type to search, arrows to move, enter to select, esc to cancel</Text>
<Box marginTop={1}>
<Text color={ORANGE}>{'> '}</Text>
<Text>{search.length > 0 ? search.toUpperCase() : ''}</Text>
<Text dimColor>{search.length === 0 ? 'search...' : ''}</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{visible.map((c, i) => {
const idx = scrollStart + i
const isActive = c.code === activeCode
const isSelected = idx === safeCursor
return (
<Text key={c.code}>
<Text color={isSelected ? ORANGE : undefined} bold={isSelected}>
{isSelected ? '> ' : ' '}
{c.code} {c.name}
{isActive ? ' *' : ''}
</Text>
</Text>
)
})}
{filtered.length === 0 && search.length >= 3 && isValidCurrencyCode(search.toUpperCase()) && (
<Text color={GOLD}> Press enter to use {search.toUpperCase()}</Text>
)}
{filtered.length === 0 && (search.length < 3 || !isValidCurrencyCode(search.toUpperCase())) && (
<Text dimColor> No matches</Text>
)}
</Box>
</Box>
)
}
function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) {
if (wide) return <Box width={width}>{children}</Box>
return <>{children}</>
@ -475,6 +587,8 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider
const [period, setPeriod] = useState<Period>(initialPeriod)
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
const [loading, setLoading] = useState(false)
const [showCurrencyPicker, setShowCurrencyPicker] = useState(false)
const [, forceRender] = useState(0)
const [activeProvider, setActiveProvider] = useState(initialProvider)
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
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 (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders} />
<CurrencyPicker width={dashWidth} onSelect={handleCurrencySelect} onCancel={handleCurrencyCancel} />
</Box>
)
}
if (loading) {
return (
<Box flexDirection="column" width={dashWidth}>

View file

@ -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) {