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