hermes-agent/ui-tui/src/components/gridStreamsDemo.tsx
Brooklyn Nicholson ed93d6afe0 feat(ui-tui): widget primitives — charts, accordion, shimmer, stable streams
Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/
hbars chart helpers (dimension-stable so live updates never resize the card),
an Accordion for expand/collapse sections, animated shimmer loaders, and a
streams demo that no longer reserves a phantom icon column on unfocused titles.
2026-07-21 20:59:54 -05:00

364 lines
10 KiB
TypeScript

import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
import { sparkRows } from '../lib/charts.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { GridTestState } from '../sdk/apps/gridTestState.js'
import type { Theme } from '../theme.js'
import { GridAreas, type GridAreaWidget } from './widgetGrid.js'
// The streams demo tiles six independently-ticking panels through
// `layoutGridAreas`: one panel owns a promoted 2x2 slot, the rest auto-place
// densely around it. Promoting a different panel reorders/respans the widget
// list — but cells are keyed by id, so every stream keeps its history while
// the grid reshapes around it. That relayout-without-reset is the point of
// the demo.
const HEADER_ROWS = 3
const STREAM_ROWS = 3
const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6
interface StreamDef {
id: string
render: (inner: { height: number; t: Theme; width: number }) => ReactNode
title: string
}
// ── tick + history hooks ────────────────────────────────────────────────────
const useTick = (ms: number) => {
const [tick, setTick] = useState(0)
useEffect(() => {
const timer = setInterval(() => setTick(v => v + 1), ms)
return () => clearInterval(timer)
}, [ms])
return tick
}
/** Ring-buffered sample history: one `sample()` per tick, capped at `cap`. */
const useHistory = (tick: number, sample: () => number, cap = 240) => {
const historyRef = useRef<number[]>([])
useEffect(() => {
historyRef.current.push(sample())
if (historyRef.current.length > cap) {
historyRef.current.splice(0, historyRef.current.length - cap)
}
// The sampler is intentionally re-run per tick, not per identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tick])
return historyRef.current
}
// ── stream panels ───────────────────────────────────────────────────────────
const TOKEN_WORDS = (
`Hermes streams tokens into the promoted cell while the grid reshapes around it. ` +
`Cells are keyed by id, so promotion never resets a panel — history, cursors and ` +
`tickers all survive the relayout. Row and column tracks re-solve to integer ` +
`terminal cells on every change, spans bridge the gaps they cross, and dense ` +
`auto-placement backfills the holes the promoted panel leaves behind. `
).split(' ')
function TokenStream({ height, t, width }: { height: number; t: Theme; width: number }) {
const tick = useTick(90)
const wordCount = tick % (TOKEN_WORDS.length * 4)
// Keep roughly enough trailing text to fill the cell, on word boundaries.
const budget = Math.max(16, width * height)
const words: string[] = []
let used = 0
for (let i = wordCount; i >= 0 && used < budget; i--) {
const word = TOKEN_WORDS[i % TOKEN_WORDS.length]!
words.unshift(word)
used += word.length + 1
}
return (
<Text color={t.color.text} wrap="wrap">
{words.join(' ')}
<Text color={t.color.primary}></Text>
</Text>
)
}
function SparkStream({
color,
format,
height,
interval,
sample,
t,
width
}: {
color: string
format: (value: number) => string
height: number
interval: number
sample: () => number
t: Theme
width: number
}) {
const tick = useTick(interval)
const history = useHistory(tick, sample)
const current = history[history.length - 1] ?? 0
// Chart height grows with the cell — a promoted cell gets a taller chart.
const rows = Math.max(1, height - 1)
return (
<Box flexDirection="column">
<Text color={t.color.muted} wrap="truncate">
{format(current)}
</Text>
{sparkRows(history, width, rows).map((line, idx) => (
<Text color={color} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
const FAKE_TOOLS = [
'┊ read_file src/app/chat.tsx',
'┊ terminal npm run typecheck',
'┊ search_files "GridAreas"',
'┊ patch ui-tui/src/lib/widgetGrid.ts',
'┊ web_search yoga absolute layout',
'┊ delegate_task refactor sparkline',
'┊ terminal scripts/run_tests.sh',
'┊ vision_analyze screenshot.png'
]
function ToolTicker({ height, t }: { height: number; t: Theme; width: number }) {
const tick = useTick(600)
const visible = Math.max(1, height)
const lines = Array.from({ length: Math.min(visible, tick + 1) }, (_, idx) => {
const line = FAKE_TOOLS[(tick - idx) % FAKE_TOOLS.length]!
return { line, recent: idx === 0 }
}).reverse()
return (
<Box flexDirection="column">
{lines.map(({ line, recent }, idx) => (
<Text color={recent ? t.color.text : t.color.muted} key={idx} wrap="truncate">
{line}
</Text>
))}
</Box>
)
}
function MetaPanel({ t }: { height: number; t: Theme; width: number }) {
const tick = useTick(1000)
const startedRef = useRef(Date.now())
const uptime = Math.floor((Date.now() - startedRef.current) / 1000)
return (
<Box flexDirection="column">
<Text color={t.color.text}>{new Date().toLocaleTimeString()}</Text>
<Text color={t.color.muted}>{`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`}</Text>
<Text color={t.color.muted}>{`${tick} ticks`}</Text>
</Box>
)
}
// ── stream registry ─────────────────────────────────────────────────────────
const sineSampler = () => (Math.sin(Date.now() / 700) + 1) / 2 + Math.random() * 0.15
let walkValue = 0.5
const walkSampler = () => {
walkValue = Math.min(1, Math.max(0, walkValue + (Math.random() - 0.5) * 0.2))
return walkValue
}
/** Exported so tests can assert the count matches GRID_STREAM_COUNT (focus wraps mod it). */
export const STREAM_DEFS: StreamDef[] = [
{
id: 'tokens',
render: ({ height, t, width }) => <TokenStream height={height} t={t} width={width} />,
title: 'token stream'
},
{
id: 'throughput',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.accent}
format={v => `${Math.round(20 + v * 40)} tok/s`}
height={height}
interval={150}
sample={sineSampler}
t={t}
width={width}
/>
),
title: 'throughput'
},
{
id: 'heap',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.ok}
format={v => `heap ${(v / 1024 / 1024).toFixed(1)} MB`}
height={height}
interval={500}
sample={() => process.memoryUsage().heapUsed}
t={t}
width={width}
/>
),
title: 'memory'
},
{
id: 'latency',
render: ({ height, t, width }) => (
<SparkStream
color={t.color.warn}
format={v => `${Math.round(20 + v * 180)} ms`}
height={height}
interval={250}
sample={walkSampler}
t={t}
width={width}
/>
),
title: 'latency'
},
{
id: 'tools',
render: ({ height, t, width }) => <ToolTicker height={height} t={t} width={width} />,
title: 'tool feed'
},
{
id: 'meta',
render: ({ height, t, width }) => <MetaPanel height={height} t={t} width={width} />,
title: 'session'
}
]
// ── the demo surface ────────────────────────────────────────────────────────
function StreamPanel({
cell,
focused,
main,
t,
title,
children
}: {
cell: GridAreaCell
children: (inner: { height: number; t: Theme; width: number }) => ReactNode
focused: boolean
main: boolean
t: Theme
title: string
}) {
const innerWidth = Math.max(1, cell.width - 4)
const innerHeight = Math.max(1, cell.height - 3)
const borderColor = focused ? t.color.primary : main ? t.color.accent : t.color.border
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
paddingX={1}
width={cell.width}
>
{/* No phantom icon column: unfocused titles sit flush left — the ▸
appears (and shifts the title) only while focused. */}
<Text bold={focused} color={focused ? t.color.primary : t.color.label} wrap="truncate">
{focused ? '▸ ' : ''}
{title}
{main ? ' ·' : ''}
</Text>
<Box flexDirection="column" height={innerHeight} overflow="hidden" width={innerWidth}>
{children({ height: innerHeight, t, width: innerWidth })}
</Box>
</Box>
)
}
export const GridStreamsDemo = memo(function GridStreamsDemo({
cols,
state,
t
}: {
cols: number
state: GridTestState
t: Theme
}) {
const columnTracks: GridTrackSize[] = [{ fr: 1 }, { fr: 1 }, { fr: 1 }]
const main = STREAM_DEFS[state.streamMain % STREAM_DEFS.length]!
// Promoted panel first so dense placement gives it the top-left 2x2; the
// rest backfill in definition order. Ids never change, so React reconciles
// each panel across promotions and its ticking state survives.
const ordered = [main, ...STREAM_DEFS.filter(def => def.id !== main.id)]
const widgets: GridAreaWidget[] = [
{
colSpan: 3,
id: 'stream-header',
render: cell => (
<Box
alignItems="center"
borderColor={t.color.border}
borderStyle="round"
height={cell.height}
justifyContent="space-between"
paddingX={1}
width={cell.width}
>
<Text bold color={t.color.primary}>
hermes mission control
</Text>
<Text color={t.color.muted}>{`main: ${main.title}`}</Text>
</Box>
)
},
...ordered.map((def, idx) => ({
colSpan: idx === 0 ? 2 : 1,
id: `stream-${def.id}`,
render: (cell: GridAreaCell) => (
<StreamPanel
cell={cell}
focused={STREAM_DEFS[state.streamFocus % STREAM_DEFS.length]!.id === def.id}
main={def.id === main.id}
t={t}
title={def.title}
>
{def.render}
</StreamPanel>
),
rowSpan: idx === 0 ? 2 : 1
}))
]
return (
<GridAreas
columns={columnTracks}
gap={1}
height={GRID_HEIGHT}
rowGap={0}
rows={[HEADER_ROWS, { fr: 1 }, { fr: 1 }, { fr: 1 }]}
widgets={widgets}
width={cols}
/>
)
})