mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add subagent details
This commit is contained in:
parent
4b4b4d47bc
commit
cb7b740e32
8 changed files with 92 additions and 866 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<ConfigFullResponse>('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,
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { WIDGET_CATALOG } from '../widgets.js'
|
||||
|
||||
export interface WidgetState {
|
||||
enabled: Record<string, boolean>
|
||||
params: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
function defaults(): WidgetState {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
|
||||
for (const w of WIDGET_CATALOG) {
|
||||
enabled[w.id] = w.defaultOn
|
||||
}
|
||||
|
||||
return { enabled, params: {} }
|
||||
}
|
||||
|
||||
export const $widgetState = atom<WidgetState>(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
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<NoSelect flexDirection="column" flexShrink={0} width={width}>
|
||||
<Box borderColor={t.color.bronze as any} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||
<WidgetHost region="sidebar" widgets={widgets} />
|
||||
</Box>
|
||||
</NoSelect>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Box onClick={onClick}>
|
||||
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => onClick(!!e?.shiftKey || !!e?.ctrlKey)}>
|
||||
<Text color={color} dimColor={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{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 (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Chevron onClick={() => setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} />
|
||||
{active && (
|
||||
<Chevron
|
||||
onClick={shift => shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })}
|
||||
open={open}
|
||||
suffix={suffix}
|
||||
t={t}
|
||||
title={title}
|
||||
tone={statusTone}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Chevron
|
||||
count={item.thinking.length}
|
||||
onClick={() => 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) && (
|
||||
<Thinking
|
||||
active={item.status === 'running'}
|
||||
mode="full"
|
||||
|
|
@ -195,12 +215,13 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub
|
|||
<>
|
||||
<Chevron
|
||||
count={item.tools.length}
|
||||
onClick={() => 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) && (
|
||||
<Box flexDirection="column">
|
||||
{item.tools.map((line, index) => (
|
||||
<Text color={t.color.cornsilk} key={`${item.id}-tool-${index}`} wrap="wrap-trim">
|
||||
|
|
@ -217,13 +238,14 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub
|
|||
<>
|
||||
<Chevron
|
||||
count={noteRows.length}
|
||||
onClick={() => 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) && (
|
||||
<Box flexDirection="column">
|
||||
{noteRows.map((line, index) => (
|
||||
<Text
|
||||
|
|
@ -339,6 +361,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
const [openThinking, setOpenThinking] = useState(false)
|
||||
const [openTools, setOpenTools] = useState(false)
|
||||
const [openSubagents, setOpenSubagents] = useState(false)
|
||||
const [deepSubagents, setDeepSubagents] = useState(false)
|
||||
const [openMeta, setOpenMeta] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -519,7 +542,9 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
: null
|
||||
|
||||
const subagentBlock = hasSubagents
|
||||
? subagents.map(item => <SubagentAccordion expanded={detailsMode === 'expanded'} item={item} key={item.id} t={t} />)
|
||||
? subagents.map(item => (
|
||||
<SubagentAccordion expanded={detailsMode === 'expanded' || deepSubagents} item={item} key={item.id} t={t} />
|
||||
))
|
||||
: 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({
|
|||
<Box flexDirection="column">
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Box onClick={() => setOpenThinking(v => !v)}>
|
||||
<Box onClick={(e: { ctrlKey?: boolean; shiftKey?: boolean }) => (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}>
|
||||
<Text color={t.color.dim} dimColor={!thinkingLive}>
|
||||
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
|
||||
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}>
|
||||
|
|
@ -586,7 +619,7 @@ export const ToolTrail = memo(function ToolTrail({
|
|||
<>
|
||||
<Chevron
|
||||
count={groups.length}
|
||||
onClick={() => 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({
|
|||
<>
|
||||
<Chevron
|
||||
count={subagents.length}
|
||||
onClick={() => 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({
|
|||
<>
|
||||
<Chevron
|
||||
count={meta.length}
|
||||
onClick={() => setOpenMeta(v => !v)}
|
||||
onClick={shift => shift ? expandAll() : setOpenMeta(v => !v)}
|
||||
open={openMeta}
|
||||
t={t}
|
||||
title="Activity"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<string, boolean>
|
||||
params?: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
// ── 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<T>(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<boolean>(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 (
|
||||
<Box flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
<Text color={color} key={i}>
|
||||
{row}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box
|
||||
backgroundColor={backgroundColor}
|
||||
borderColor={bordered ? (borderColor ?? t.color.bronze) : undefined}
|
||||
borderStyle={bordered ? 'round' : undefined}
|
||||
flexDirection="column"
|
||||
paddingX={paddingX}
|
||||
paddingY={paddingY}
|
||||
>
|
||||
<Box justifyContent="space-between">
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
{typeof title === 'string' || typeof title === 'number' ? (
|
||||
<Text bold color={(titleTone ?? t.color.gold) as any} wrap="truncate-end">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Box>
|
||||
{titleRight ? (
|
||||
<Box flexDirection="row" flexShrink={0} marginLeft={1}>
|
||||
{typeof titleRight === 'string' || typeof titleRight === 'number' ? (
|
||||
<Text color={t.color.dim as any}>{titleRight}</Text>
|
||||
) : (
|
||||
titleRight
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Text color={t.color.dim as any} wrap="truncate-end">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
{asset.label}
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk as any}>{` ${money(last)} `}</Text>
|
||||
<Text bold color={color as any}>
|
||||
{changeStr(change)}
|
||||
</Text>
|
||||
<Text color={t.color.dim as any}>{` ${sparkline(pts, sparkW)} `}</Text>
|
||||
<Text color={t.color.dim as any}>{others}</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
Weather
|
||||
</Text>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">{`${icon} ${fToC(temp)}C · ${desc}`}</Text>
|
||||
<Text color={t.color.dim as any} wrap="truncate-end">{`Wind ${wind} km/h · Humidity ${hum}%`}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>{`${orb} World Clock`}</Text>
|
||||
{rows.map((row, i) => (
|
||||
<Box flexDirection="row" key={i}>
|
||||
<Box marginRight={1} width={slotW}>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">
|
||||
{cell(row[0]!.label, row[0]!.tz)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={slotW}>
|
||||
<Text color={t.color.cornsilk as any} wrap="truncate-end">
|
||||
{cell(row[1]!.label, row[1]!.tz)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function HeartBeat({ t }: WidgetCtx) {
|
||||
const tick = useWidgetTicker(700)
|
||||
const bpm = 70 + (tick % 5)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={t.color.gold as any}>
|
||||
Heartbeat
|
||||
</Text>
|
||||
<Text color={t.color.statusBad as any}>{`❤️ ${bpm} bpm`}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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: <TickerStrip {...ctx} assetId={param('ticker', 'asset')} t={bt} />,
|
||||
order: 10,
|
||||
region: 'dock'
|
||||
},
|
||||
{ id: 'world-clock', node: <WorldClock {...ctx} />, order: 10, region: 'sidebar' },
|
||||
{ id: 'weather', node: <WeatherCard {...ctx} />, order: 20, region: 'sidebar' },
|
||||
{ id: 'heartbeat', node: <HeartBeat {...ctx} />, 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 => (
|
||||
<Fragment key={w.id}>{w.node}</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visible.map((w, i) => (
|
||||
<Box flexDirection="column" key={w.id} marginTop={i === 0 ? 0 : 1}>
|
||||
{w.node}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue