mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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.
364 lines
10 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
})
|