diff --git a/run_agent.py b/run_agent.py index 956093748..8731cbaa0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10122,7 +10122,14 @@ class AIAgent: elif self.quiet_mode: clean = self._strip_think_blocks(turn_content).strip() if clean: - self._vprint(f" โ”Š ๐Ÿ’ฌ {clean}") + relayed = False + if ( + self.tool_progress_callback + and getattr(self, "platform", "") == "tui" + ): + relayed = True + if not relayed: + self._vprint(f" โ”Š ๐Ÿ’ฌ {clean}") # Pop thinking-only prefill message(s) before appending # (tool-call path โ€” same rationale as the final-response path). diff --git a/ui-tui/src/__tests__/widgets.test.ts b/ui-tui/src/__tests__/widgets.test.ts deleted file mode 100644 index 39beef908..000000000 --- a/ui-tui/src/__tests__/widgets.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { DEFAULT_THEME } from '../theme.js' -import type { WidgetSpec } from '../widgets.js' -import { - bloombergTheme, - buildWidgets, - cityTime, - livePoints, - marquee, - plotLineRows, - sparkline, - widgetsInRegion, - wrapWindow -} from '../widgets.js' - -const BASE_CTX = { - bgCount: 0, - busy: false, - cols: 120, - cwdLabel: '~/hermes-agent', - durationLabel: '11s', - model: 'claude', - status: 'idle', - t: DEFAULT_THEME, - usage: { calls: 0, input: 0, output: 0, total: 0 }, - voiceLabel: 'voice off' -} - -describe('sparkline', () => { - it('respects requested width', () => { - expect([...sparkline([1, 2, 3, 4, 5], 3)]).toHaveLength(3) - }) - - it('is stable for flat series', () => { - const line = sparkline([7, 7, 7, 7], 4) - - expect([...line]).toHaveLength(4) - expect(new Set([...line]).size).toBe(1) - }) -}) - -describe('widgetsInRegion', () => { - it('filters and sorts by region + order', () => { - const widgets: WidgetSpec[] = [ - { id: 'c', node: 'c', order: 20, region: 'dock' }, - { id: 'a', node: 'a', order: 5, region: 'dock' }, - { id: 'b', node: 'b', order: 1, region: 'overlay' } - ] - - expect(widgetsInRegion(widgets, 'dock').map(w => w.id)).toEqual(['a', 'c']) - }) -}) - -describe('wrapWindow', () => { - it('wraps around the array', () => { - expect(wrapWindow([1, 2, 3], 2, 5)).toEqual([3, 1, 2, 3, 1]) - }) -}) - -describe('marquee', () => { - it('returns fixed-width slice', () => { - expect(marquee('abc', 0, 5)).toHaveLength(5) - expect(marquee('abc', 1, 5)).not.toEqual(marquee('abc', 0, 5)) - }) -}) - -describe('plotLineRows', () => { - it('returns the requested height', () => { - expect(plotLineRows([1, 2, 3, 4], 4, 3)).toHaveLength(3) - }) - - it('each row has the requested width', () => { - const rows = plotLineRows([1, 4, 2, 5], 20, 4) - - for (const row of rows) { - expect([...row]).toHaveLength(20) - } - }) - - it('draws visible braille for varied data', () => { - expect(plotLineRows([1, 4, 2, 5], 8, 3).join('')).toMatch(/[^\u2800 ]/) - }) -}) - -describe('livePoints', () => { - const asset = { label: 'TEST', series: [100, 102, 101, 103, 105] } - - it('extends the series by one', () => { - expect(livePoints(asset, 0)).toHaveLength(asset.series.length + 1) - }) - - it('starts at series[0]', () => { - expect(livePoints(asset, 0)[0]).toBe(100) - }) - - it('live point stays within ยฑ2% of last value', () => { - const last = asset.series.at(-1)! - - for (let t = 0; t < 50; t++) { - const pts = livePoints(asset, t) - const live = pts.at(-1)! - - expect(live).toBeGreaterThan(last * 0.98) - expect(live).toBeLessThan(last * 1.02) - } - }) -}) - -describe('cityTime', () => { - it('returns HH:MM:SS format', () => { - expect(cityTime('America/New_York')).toMatch(/^\d{2}:\d{2}:\d{2}$/) - }) - - it('works for all clock zones', () => { - for (const tz of ['America/New_York', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney']) { - expect(cityTime(tz)).toMatch(/^\d{2}:\d{2}:\d{2}$/) - } - }) -}) - -describe('buildWidgets', () => { - it('routes widgets into dock + sidebar regions', () => { - const widgets = buildWidgets({ ...BASE_CTX, blocked: true }) - const byId = new Map(widgets.map(w => [w.id, w.region])) - - expect(byId.get('ticker')).toBe('dock') - expect(byId.get('world-clock')).toBe('sidebar') - expect(byId.get('weather')).toBe('sidebar') - expect(byId.get('heartbeat')).toBe('sidebar') - }) - - it('filters by enabled map', () => { - const enabled = { ticker: true, 'world-clock': false, weather: true, heartbeat: false } - const widgets = buildWidgets(BASE_CTX, { enabled }) - const ids = widgets.map(w => w.id) - - expect(ids).toContain('ticker') - expect(ids).toContain('weather') - expect(ids).not.toContain('world-clock') - expect(ids).not.toContain('heartbeat') - }) - - it('accepts widget params config', () => { - const widgets = buildWidgets(BASE_CTX, { - enabled: { ticker: true, 'world-clock': true, weather: true, heartbeat: true }, - params: { ticker: { asset: 'ETH' } } - }) - - expect(widgets.map(w => w.id)).toContain('ticker') - }) - - it('returns all when no enabled map given', () => { - const widgets = buildWidgets({ ...BASE_CTX, blocked: false }) - - expect(widgets.some(w => w.region === 'overlay')).toBe(false) - expect(widgets.some(w => w.region === 'dock')).toBe(true) - }) - - it('includes all expected widget ids', () => { - const ids = buildWidgets(BASE_CTX).map(w => w.id) - - expect(ids).toContain('ticker') - expect(ids).toContain('weather') - expect(ids).toContain('world-clock') - expect(ids).toContain('heartbeat') - }) -}) - -describe('bloombergTheme', () => { - it('overrides color keys while preserving brand', () => { - const bt = bloombergTheme(DEFAULT_THEME) - - expect(bt.brand).toEqual(DEFAULT_THEME.brand) - expect(bt.color.cornsilk).toBe('#FFFFFF') - expect(bt.color.statusGood).toBe('#00EE00') - expect(bt.color.statusBad).toBe('#FF2200') - }) -}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 98e6149b1..549314abd 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -109,12 +109,6 @@ export function App({ gw }: { gw: GatewayClient }) { const composerActions = composer.actions const composerRefs = composer.refs const composerState = composer.state - const composerCompletions = composerState.completions - const composerCompIdx = composerState.compIdx - const composerInput = composerState.input - const composerInputBuf = composerState.inputBuf - const composerQueueEditIdx = composerState.queueEditIdx - const composerQueuedDisplay = composerState.queuedDisplay const empty = !historyItems.some(msg => msg.kind !== 'intro') @@ -232,10 +226,6 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) - const pushActivity = turnActions.pushActivity - const pruneTransient = turnActions.pruneTransient - const pushTrail = turnActions.pushTrail - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} @@ -353,7 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - pushActivity('MCP reloaded after config change') + turnActions.pushActivity('MCP reloaded after config change') }) rpc('config.get', { key: 'full' }).then(applyDisplayConfig) } else if (!configMtimeRef.current && next) { @@ -363,7 +353,7 @@ export function App({ gw }: { gw: GatewayClient }) { }, 5000) return () => clearInterval(id) - }, [applyDisplayConfig, pushActivity, rpc, ui.sid]) + }, [applyDisplayConfig, turnActions, rpc, ui.sid]) const idle = turnActions.idle const clearReasoning = turnActions.clearReasoning @@ -606,9 +596,9 @@ export function App({ gw }: { gw: GatewayClient }) { if (r?.matched) { if (r.is_image) { const meta = imageTokenMeta(r) - pushActivity(`attached image: ${r.name}${meta ? ` ยท ${meta}` : ''}`) + turnActions.pushActivity(`attached image: ${r.name}${meta ? ` ยท ${meta}` : ''}`) } else { - pushActivity(`detected file: ${r.name}`) + turnActions.pushActivity(`detected file: ${r.name}`) } startSubmit(r.text || text, expandPasteSnips(r.text || text)) @@ -620,7 +610,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => startSubmit(text, expandPasteSnips(text))) }, - [appendMessage, composerState.pasteSnips, gw, pushActivity, sys, turnRefs] + [appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs] ) const shellExec = useCallback( @@ -850,10 +840,10 @@ export function App({ gw }: { gw: GatewayClient }) { clearReasoning, endReasoningPhase: turnActions.endReasoningPhase, idle, - pruneTransient, + pruneTransient: turnActions.pruneTransient, pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity, - pushTrail, + pushActivity: turnActions.pushActivity, + pushTrail: turnActions.pushTrail, scheduleReasoning: turnActions.scheduleReasoning, scheduleStreaming: turnActions.scheduleStreaming, setActivity: turnActions.setActivity, @@ -888,9 +878,6 @@ export function App({ gw }: { gw: GatewayClient }) { gateway, idle, newSession, - pruneTransient, - pushActivity, - pushTrail, resetSession, sendQueued, sys, @@ -907,7 +894,7 @@ export function App({ gw }: { gw: GatewayClient }) { const exitHandler = () => { patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - pushActivity('gateway exited ยท /logs to inspect', 'error') + turnActions.pushActivity('gateway exited ยท /logs to inspect', 'error') sys('error: gateway exited') } @@ -920,7 +907,7 @@ export function App({ gw }: { gw: GatewayClient }) { gw.off('exit', exitHandler) gw.kill() } - }, [gw, pushActivity, sys]) + }, [gw, turnActions, sys]) // โ”€โ”€ Slash commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -1162,27 +1149,27 @@ export function App({ gw }: { gw: GatewayClient }) { const appComposer = useMemo( () => ({ cols, - compIdx: composerCompIdx, - completions: composerCompletions, + compIdx: composerState.compIdx, + completions: composerState.completions, empty, handleTextPaste, - input: composerInput, - inputBuf: composerInputBuf, + input: composerState.input, + inputBuf: composerState.inputBuf, pagerPageSize, - queueEditIdx: composerQueueEditIdx, - queuedDisplay: composerQueuedDisplay, + queueEditIdx: composerState.queueEditIdx, + queuedDisplay: composerState.queuedDisplay, submit, updateInput: composerActions.setInput }), [ cols, composerActions.setInput, - composerCompIdx, - composerCompletions, - composerInput, - composerInputBuf, - composerQueueEditIdx, - composerQueuedDisplay, + composerState.compIdx, + composerState.completions, + composerState.input, + composerState.inputBuf, + composerState.queueEditIdx, + composerState.queuedDisplay, empty, handleTextPaste, pagerPageSize, diff --git a/ui-tui/src/app/widgetStore.ts b/ui-tui/src/app/widgetStore.ts deleted file mode 100644 index 51fe3e821..000000000 --- a/ui-tui/src/app/widgetStore.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { atom } from 'nanostores' - -import { WIDGET_CATALOG } from '../widgets.js' - -export interface WidgetState { - enabled: Record - params: Record> -} - -function defaults(): WidgetState { - const enabled: Record = {} - - for (const w of WIDGET_CATALOG) { - enabled[w.id] = w.defaultOn - } - - return { enabled, params: {} } -} - -export const $widgetState = atom(defaults()) - -export function toggleWidget(id: string, force?: boolean) { - const s = $widgetState.get() - const next = force ?? !s.enabled[id] - - $widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } }) - - return next -} - -export function setWidgetParam(id: string, key: string, value: string) { - const s = $widgetState.get() - const prev = s.params[id] ?? {} - - $widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } }) -} - -export function getWidgetEnabled(id: string): boolean { - return $widgetState.get().enabled[id] ?? false -} diff --git a/ui-tui/src/components/sidebarRail.tsx b/ui-tui/src/components/sidebarRail.tsx deleted file mode 100644 index 80b52078c..000000000 --- a/ui-tui/src/components/sidebarRail.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Box, NoSelect } from '@hermes/ink' - -import type { Theme } from '../theme.js' -import type { WidgetSpec } from '../widgets.js' -import { WidgetHost } from '../widgets.js' - -export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) { - return ( - - - - - - ) -} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index afd00e4a2..27bf5e073 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -103,7 +103,7 @@ function Chevron({ tone = 'dim' }: { count?: number - onClick: () => void + onClick: (deep?: boolean) => void open: boolean suffix?: string t: Theme @@ -113,7 +113,7 @@ function Chevron({ const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim return ( - + onClick(!!e?.shiftKey || !!e?.ctrlKey)}> {open ? 'โ–พ ' : 'โ–ธ '} {title} @@ -131,6 +131,7 @@ function Chevron({ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) { const [open, setOpen] = useState(expanded) + const [deep, setDeep] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded) const [openTools, setOpenTools] = useState(expanded) const [openNotes, setOpenNotes] = useState(expanded) @@ -141,16 +142,26 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub } setOpen(true) + setDeep(true) setOpenThinking(true) setOpenTools(true) setOpenNotes(true) }, [expanded]) + const expandAll = () => { + setOpen(true) + setDeep(true) + setOpenThinking(true) + setOpenTools(true) + setOpenNotes(true) + } + const statusTone: 'dim' | 'error' | 'warn' = item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' - const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}` + const goalLabel = item.goal || `Subagent ${item.index + 1}` + const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) const suffix = @@ -163,23 +174,32 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub const hasTools = item.tools.length > 0 const noteRows = [...(summary ? [summary] : []), ...item.notes] const hasNotes = noteRows.length > 0 - const active = expanded || open + const showChildren = expanded || deep return ( - setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} /> - {active && ( + shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })} + open={open} + suffix={suffix} + t={t} + title={title} + tone={statusTone} + /> + + {open && ( {hasThinking && ( <> setOpenThinking(v => !v)} - open={expanded || openThinking} + onClick={shift => { if (shift) expandAll(); else setOpenThinking(v => !v) }} + open={showChildren || openThinking} t={t} title="Thinking" /> - {(expanded || openThinking) && ( + + {(showChildren || openThinking) && ( setOpenTools(v => !v)} - open={expanded || openTools} + onClick={shift => { if (shift) expandAll(); else setOpenTools(v => !v) }} + open={showChildren || openTools} t={t} title="Tool calls" /> - {(expanded || openTools) && ( + + {(showChildren || openTools) && ( {item.tools.map((line, index) => ( @@ -217,13 +238,14 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub <> setOpenNotes(v => !v)} - open={expanded || openNotes} + onClick={shift => { if (shift) expandAll(); else setOpenNotes(v => !v) }} + open={showChildren || openNotes} t={t} title="Progress" tone={statusTone} /> - {(expanded || openNotes) && ( + + {(showChildren || openNotes) && ( {noteRows.map((line, index) => ( { @@ -519,7 +542,9 @@ export const ToolTrail = memo(function ToolTrail({ : null const subagentBlock = hasSubagents - ? subagents.map(item => ) + ? subagents.map(item => ( + + )) : null const metaBlock = hasMeta @@ -554,6 +579,14 @@ export const ToolTrail = memo(function ToolTrail({ // โ”€โ”€ Collapsed: clickable accordions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const expandAll = () => { + setOpenThinking(true) + setOpenTools(true) + setOpenSubagents(true) + setDeepSubagents(true) + setOpenMeta(true) + } + const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') ? 'error' : activity.some(i => i.tone === 'warn') @@ -564,7 +597,7 @@ export const ToolTrail = memo(function ToolTrail({ {hasThinking && ( <> - setOpenThinking(v => !v)}> + (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}> {openThinking ? 'โ–พ ' : 'โ–ธ '} @@ -586,7 +619,7 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenTools(v => !v)} + onClick={shift => shift ? expandAll() : setOpenTools(v => !v)} open={openTools} suffix={toolTokensLabel} t={t} @@ -600,7 +633,15 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenSubagents(v => !v)} + onClick={shift => { + if (shift) { + expandAll() + setDeepSubagents(true) + } else { + setOpenSubagents(v => !v) + setDeepSubagents(false) + } + }} open={openSubagents} t={t} title="Subagents" @@ -613,7 +654,7 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenMeta(v => !v)} + onClick={shift => shift ? expandAll() : setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 70dbb536f..c6ba28c80 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -4,6 +4,7 @@ import type { CompletionItem } from '../app/interfaces.js' import type { GatewayClient } from '../gatewayClient.js' import type { CompletionResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' + const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { diff --git a/ui-tui/src/widgets.tsx b/ui-tui/src/widgets.tsx deleted file mode 100644 index ffc9a864f..000000000 --- a/ui-tui/src/widgets.tsx +++ /dev/null @@ -1,576 +0,0 @@ -import { Box, Text } from '@hermes/ink' -import { Fragment, type ReactNode, useEffect, useState } from 'react' - -import type { Theme } from './theme.js' -import type { Usage } from './types.js' - -// โ”€โ”€ Region types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export const WIDGET_REGIONS = [ - 'transcript-header', - 'transcript-inline', - 'transcript-tail', - 'dock', - 'overlay', - 'sidebar' -] as const -export type WidgetRegion = (typeof WIDGET_REGIONS)[number] - -export interface WidgetCtx { - blocked?: boolean - bgCount: number - busy: boolean - cols: number - cwdLabel?: string - durationLabel?: string - model?: string - status: string - t: Theme - // tick is intentionally NOT here โ€” each widget calls useWidgetTicker() internally. - // Passing tick via props caused useMemo in AppLayout to rebuild JSX on every second, - // which created stale prop snapshots and broke animated text rendering. - usage: Usage - voiceLabel?: string -} - -export interface WidgetSpec { - id: string - node: ReactNode - order?: number - region: WidgetRegion - // Optional: theme transform applied to `t` before rendering. This lets - // individual widgets opt into a different color palette (e.g. Bloomberg) - // without touching the main app theme. - themeOverride?: (base: Theme) => Theme -} - -export interface WidgetRenderState { - enabled?: Record - params?: Record> -} - -// โ”€โ”€ Theme overrides โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// Bloomberg terminal palette: high-contrast orange/green/red on dark intent. -export function bloombergTheme(t: Theme): Theme { - return { - ...t, - color: { - ...t.color, - gold: '#FFE000', // bright yellow titles - amber: '#FF8C00', // orange for values - bronze: '#FF6600', // orange borders - cornsilk: '#FFFFFF', // white for primary text - dim: '#777777', // gray secondary - label: '#FFCC00', // amber labels - statusGood: '#00EE00', // classic Bloomberg green - statusBad: '#FF2200', // Bloomberg red - statusWarn: '#FFAA00' - } - } -} - -// โ”€โ”€ Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -interface Asset { - label: string - series: number[] -} - -const BTC: number[] = [ - 83900, 84210, 85140, 84680, 85990, 86540, 87310, 86820, 87940, 88600, 89200, 90100, 90560, 91840, 91210, 92680, 92100, - 91500, 92900, 93200 -] - -const ETH: number[] = [ - 2920, 2960, 3015, 2990, 3070, 3050, 3110, 3080, 3160, 3200, 3225, 3260, 3290, 3250, 3310, 3330, 3385, 3360, 3400, 3420 -] - -const NVDA: number[] = [ - 125, 128, 129, 127, 131, 130, 133, 132, 136, 137, 139, 138, 141, 140, 142, 143, 145, 144, 146, 148 -] - -const TSLA: number[] = [ - 172, 176, 178, 175, 181, 180, 184, 182, 188, 187, 191, 189, 195, 193, 196, 198, 201, 199, 203, 205 -] - -const TEMP_DAY: number[] = [65, 66, 67, 69, 71, 73, 74, 75, 74, 73, 72, 71, 70, 69, 68] - -const USD = new Intl.NumberFormat('en-US', { currency: 'USD', maximumFractionDigits: 0, style: 'currency' }) -const BARS = ['โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ'] -const ORBS = ['โ—', 'โ—“', 'โ—‘', 'โ—’'] - -const ASSETS: Asset[] = [ - { label: 'BTC', series: BTC }, - { label: 'ETH', series: ETH }, - { label: 'NVDA', series: NVDA }, - { label: 'TSLA', series: TSLA } -] - -const CLOCKS = [ - { label: 'NYC', tz: 'America/New_York' }, - { label: 'LON', tz: 'Europe/London' }, - { label: 'TKY', tz: 'Asia/Tokyo' }, - { label: 'SYD', tz: 'Australia/Sydney' } -] - -const SKY_DESC = ['Overcast', 'Partly cloudy', 'Clear'] -const SKY_ICON = ['โ˜', 'โ›…', 'โ˜€'] - -// โ”€โ”€ Ticker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export function useWidgetTicker(ms = 1000) { - const [tick, setTick] = useState(0) - - useEffect(() => { - const id = setInterval(() => setTick(v => v + 1), ms) - - return () => clearInterval(id) - }, [ms]) - - return tick -} - -// โ”€โ”€ Pure math helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// Always resamples to exactly `width` points (no early return for small input). -// The old `if (values.length <= width) return values` caused the braille grid -// to only fill the first N pixel-columns when N < pxWidth. -function sample(values: number[], width: number): number[] { - if (!values.length || width <= 0) { - return [] - } - - if (width === 1) { - return [values.at(-1) ?? 0] - } - - return Array.from({ length: width }, (_, i) => { - const pos = (i * (values.length - 1)) / (width - 1) - - return values[Math.round(pos)] ?? values.at(-1) ?? 0 - }) -} - -export function sparkline(values: number[], width: number): string { - const pts = sample(values, width) - - if (!pts.length) { - return '' - } - - const lo = Math.min(...pts) - const hi = Math.max(...pts) - - if (lo === hi) { - return BARS[Math.floor(BARS.length / 2)]!.repeat(pts.length) - } - - return pts - .map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1))))]!) - .join('') -} - -export function wrapWindow(values: T[], offset: number, width: number): T[] { - if (!values.length || width <= 0) { - return [] - } - - const start = ((offset % values.length) + values.length) % values.length - - return Array.from({ length: width }, (_, i) => values[(start + i) % values.length]!) -} - -export function marquee(text: string, offset: number, width: number): string { - if (!text || width <= 0) { - return '' - } - - const gap = ' ' - const source = text + gap + text - const span = text.length + gap.length - const start = ((offset % span) + span) % span - - return source.slice(start, start + width).padEnd(width, ' ') -} - -// โ”€โ”€ Braille line chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function brailleBit(dx: number, dy: number): number { - return dx === 0 ? ([0x1, 0x2, 0x4, 0x40][dy] ?? 0) : ([0x8, 0x10, 0x20, 0x80][dy] ?? 0) -} - -function drawLine(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) { - let x = x0 - let y = y0 - const dx = Math.abs(x1 - x0) - const sx = x0 < x1 ? 1 : -1 - const dy = -Math.abs(y1 - y0) - const sy = y0 < y1 ? 1 : -1 - let err = dx + dy - - while (true) { - if (grid[y] && x >= 0 && x < grid[y]!.length) { - grid[y]![x] = true - } - - if (x === x1 && y === y1) { - return - } - - const e2 = err * 2 - - if (e2 >= dy) { - err += dy - x += sx - } - - if (e2 <= dx) { - err += dx - y += sy - } - } -} - -export function plotLineRows(values: number[], width: number, height: number): string[] { - if (!values.length || width <= 0 || height <= 0) { - return [] - } - - const pxW = Math.max(2, width * 2) - const pxH = Math.max(4, height * 4) - const pts = sample(values, pxW) - const lo = Math.min(...pts) - const hi = Math.max(...pts) - const grid = Array.from({ length: pxH }, () => new Array(pxW).fill(false)) - - const yFor = (v: number) => (hi === lo ? Math.floor((pxH - 1) / 2) : Math.round(((hi - v) / (hi - lo)) * (pxH - 1))) - - let prevY = yFor(pts[0] ?? 0) - - if (grid[prevY]) { - grid[prevY]![0] = true - } - - for (let x = 1; x < pts.length; x++) { - const y = yFor(pts[x] ?? pts[x - 1] ?? 0) - drawLine(grid, x - 1, prevY, x, y) - prevY = y - } - - return Array.from({ length: height }, (_, row) => { - const top = row * 4 - - return Array.from({ length: width }, (_, col) => { - const left = col * 2 - let bits = 0 - - for (let dy = 0; dy < 4; dy++) { - for (let dx = 0; dx < 2; dx++) { - if (grid[top + dy]?.[left + dx]) { - bits |= brailleBit(dx, dy) - } - } - } - - return String.fromCodePoint(0x2800 + bits) - }).join('') - }) -} - -// โ”€โ”€ Domain helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function pct(now: number, start: number): number { - return !start ? 0 : ((now - start) / start) * 100 -} - -function deltaColor(delta: number, t: Theme): string { - return delta > 0 ? t.color.statusGood : delta < 0 ? t.color.statusBad : t.color.dim -} - -function money(v: number): string { - return USD.format(v) -} - -function changeStr(v: number): string { - return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` -} - -export function cityTime(tz: string): string { - try { - return new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - hour12: false, - minute: '2-digit', - second: '2-digit', - timeZone: tz - }) - } catch { - return '--:--:--' - } -} - -function fToC(f: number): number { - return Math.round(((f - 32) * 5) / 9) -} - -// Smooth live points โ€” always starts at series[0], adds an animated live -// endpoint oscillating ยฑ0.8% so the chart never has discontinuous jumps. -export function livePoints(asset: Asset, tick: number): number[] { - const last = asset.series.at(-1) ?? 0 - const phase = (tick * 0.3) % (Math.PI * 2) - const live = Math.round(last * (1 + Math.sin(phase) * 0.008)) - - return [...asset.series, live] -} - -// โ”€โ”€ Primitive components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function LineChart({ color, height, values, width }: { color: any; height: number; values: number[]; width: number }) { - const rows = plotLineRows(values, width, height) - - return ( - - {rows.map((row, i) => ( - - {row} - - ))} - - ) -} - -// Simple widget frame system: -// - bordered widgets: default card chrome -// - bleed widgets: full-surface background with internal padding -function WidgetFrame({ - backgroundColor, - bordered = true, - borderColor, - children, - paddingX = 1, - paddingY = 0, - title, - titleRight, - titleTone, - t -}: { - backgroundColor?: any - bordered?: boolean - borderColor?: any - children: ReactNode - paddingX?: number - paddingY?: number - title: ReactNode - titleRight?: ReactNode - titleTone?: any - t: Theme -}) { - return ( - - - - {typeof title === 'string' || typeof title === 'number' ? ( - - {title} - - ) : ( - title - )} - - {titleRight ? ( - - {typeof titleRight === 'string' || typeof titleRight === 'number' ? ( - {titleRight} - ) : ( - titleRight - )} - - ) : null} - - {children} - - ) -} - -// โ”€โ”€ Widgets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -// Bloomberg-styled hero chart. -// Dark navy background for the chart cell so the green/red line pops. -// Custom layout (not Card) for full control over the title row structure. -// Compact single-line ticker strip for the dock region. -function TickerStrip({ cols, t, assetId }: WidgetCtx & { assetId?: string }) { - const tick = useWidgetTicker(1200) - - const asset = ASSETS.find(a => a.label.toLowerCase() === assetId?.toLowerCase()) ?? ASSETS[tick % ASSETS.length]! - - const pts = livePoints(asset, tick) - const last = pts.at(-1) ?? 0 - const first = pts[0] ?? 0 - const change = pct(last, first) - const color = deltaColor(change, t) - const sparkW = Math.max(8, Math.min(30, cols - 40)) - - const others = ASSETS.filter(a => a.label !== asset.label) - .map(a => { - const c = pct(a.series.at(-1) ?? 0, a.series[0] ?? 0) - - return `${a.label} ${changeStr(c)}` - }) - .join(' ') - - return ( - - - {asset.label} - - {` ${money(last)} `} - - {changeStr(change)} - - {` ${sparkline(pts, sparkW)} `} - {others} - - ) -} - -function WeatherCard({ t }: WidgetCtx) { - const tick = useWidgetTicker(2000) - const skyIdx = Math.floor(tick / 8) % SKY_DESC.length - const desc = SKY_DESC[skyIdx]! - const icon = SKY_ICON[skyIdx]! - const temp = TEMP_DAY[tick % TEMP_DAY.length]! - const wind = 9 + ((tick * 2) % 7) - const hum = 64 + (tick % 8) - - return ( - - - Weather - - {`${icon} ${fToC(temp)}C ยท ${desc}`} - {`Wind ${wind} km/h ยท Humidity ${hum}%`} - - ) -} - -// 2x2 clock grid in compact rows -function WorldClock({ cols, t }: WidgetCtx) { - const tick = useWidgetTicker() - const orb = ORBS[tick % ORBS.length]! - const rows = [CLOCKS.slice(0, 2), CLOCKS.slice(2, 4)] as const - const slotW = Math.max(12, Math.floor((Math.max(cols, 24) - 2) / 2)) - const cell = (label: string, tz: string) => `${label} ${cityTime(tz)}` - - return ( - - {`${orb} World Clock`} - {rows.map((row, i) => ( - - - - {cell(row[0]!.label, row[0]!.tz)} - - - - - {cell(row[1]!.label, row[1]!.tz)} - - - - ))} - - ) -} - -function HeartBeat({ t }: WidgetCtx) { - const tick = useWidgetTicker(700) - const bpm = 70 + (tick % 5) - - return ( - - - Heartbeat - - {`โค๏ธ ${bpm} bpm`} - - ) -} - -// โ”€โ”€ Widget catalog โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export interface WidgetDef { - id: string - description: string - region: WidgetRegion - order: number - defaultOn: boolean - params?: string[] -} - -export const WIDGET_CATALOG: WidgetDef[] = [ - { - id: 'ticker', - description: 'Live stock ticker strip', - region: 'dock', - order: 10, - defaultOn: true, - params: ['asset'] - }, - { id: 'world-clock', description: '2x2 world clock grid', region: 'sidebar', order: 10, defaultOn: true }, - { id: 'weather', description: 'Weather conditions', region: 'sidebar', order: 20, defaultOn: true }, - { id: 'heartbeat', description: 'Heartbeat monitor', region: 'sidebar', order: 30, defaultOn: true } -] - -// โ”€โ”€ Registry โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export function buildWidgets(ctx: WidgetCtx, state?: WidgetRenderState): WidgetSpec[] { - const bt = bloombergTheme(ctx.t) - const enabled = state?.enabled - const params = state?.params - const on = (id: string) => (enabled ? (enabled[id] ?? false) : true) - const param = (id: string, key: string) => params?.[id]?.[key] - - const all: WidgetSpec[] = [ - { - id: 'ticker', - node: , - order: 10, - region: 'dock' - }, - { id: 'world-clock', node: , order: 10, region: 'sidebar' }, - { id: 'weather', node: , order: 20, region: 'sidebar' }, - { id: 'heartbeat', node: , order: 30, region: 'sidebar' } - ] - - return all.filter(w => on(w.id)) -} - -export function widgetsInRegion(widgets: WidgetSpec[], region: WidgetRegion) { - return [...widgets].filter(w => w.region === region).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) -} - -export function WidgetHost({ region, widgets }: { region: WidgetRegion; widgets: WidgetSpec[] }) { - const visible = widgetsInRegion(widgets, region) - - if (!visible.length) { - return null - } - - if (region === 'overlay') { - return ( - <> - {visible.map(w => ( - {w.node} - ))} - - ) - } - - return ( - - {visible.map((w, i) => ( - - {w.node} - - ))} - - ) -}