Files
hermes-agent/ui-tui/src/components/gridTestOverlay.tsx
T
Brooklyn Nicholson d8fcab4736 feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps
The SDK the desktop app already has, ported to the TUI: a WidgetApp contract
(id/help/mode/init/reduce/render/usage), a registry, and a host that owns the
active widget, routes input to its reducer, and renders it. The grid-test and
dialog-test debug surfaces are reimplemented as widget apps instead of bespoke
overlay state, and slash commands are generated from the registry. Input for an
open widget is owned by the active app (supersedes the demo-only stacked-modal
routing) — the single active widget enforces topmost-owns-input structurally.
2026-07-21 20:59:52 -05:00

319 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Box, Text } from '@hermes/ink'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
import type { GridTestState } from '../sdk/apps/gridTestState.js'
import type { Theme } from '../theme.js'
import { GridStreamsDemo } from './gridStreamsDemo.js'
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
interface GridTestOverlayProps {
cols: number
state: GridTestState
t: Theme
}
const NESTED_GAP = 1
// Heights are odd so a single line is true-centered: border, blank, text, blank, border.
const FLAT_CELL_HEIGHT = 5
const NESTED_CELL_HEIGHT = 9
const MINI_CELL_HEIGHT = 3
// Sparse "every-other" pattern so the nested toggle visibly differs from the active cell.
const showsNestedPreview = (row: number, col: number) => row % 2 === 0 && col % 2 === 0
export function GridTestOverlay({ cols, state, t }: GridTestOverlayProps) {
const gridCols = Math.max(12, cols)
const activeIdx = state.activeRow * state.cols + state.activeCol
const activeLabel = `c${activeIdx + 1}`
const widgets: WidgetGridWidget[] = Array.from({ length: state.rows * state.cols }, (_, idx) => {
const row = Math.floor(idx / state.cols)
const col = idx % state.cols
const active = idx === activeIdx
const label = `c${idx + 1}`
return {
id: `cell-${idx}`,
render: width => (
<GridCell
active={active}
label={label}
nested={state.nested && showsNestedPreview(row, col)}
nestedMode={state.nested}
t={t}
width={width}
/>
)
}
})
return (
<Box flexDirection="column" paddingY={1} width={gridCols}>
<Box justifyContent="space-between" marginBottom={1} width="100%">
<Text bold color={t.color.primary}>
{state.zoomed ? `/grid-test / r${state.activeRow + 1} c${state.activeCol + 1}` : '/grid-test'}
</Text>
<Text color={t.color.muted}>{state.streams ? 'streams' : `${state.cols}x${state.rows} grid`}</Text>
</Box>
<Text color={t.color.muted} wrap="truncate">
{state.zoomed
? 'arrows/hjkl switch cell · Esc/q back · Ctrl+C close'
: state.streams
? 'arrows/hjkl focus · Enter promote · d dialog · Esc/q back · Ctrl+C close'
: 'arrows/hjkl move · Enter zoom · d dialog · a areas · s streams · +/- cols · [] rows · g gap · p pad · n nest · q close'}
</Text>
<Box marginTop={1}>
{state.zoomed ? (
<ZoomedGridCell cols={gridCols} parentLabel={activeLabel} t={t} />
) : state.streams ? (
<GridStreamsDemo cols={gridCols} state={state} t={t} />
) : state.areas ? (
<AreasDemo cols={gridCols} state={state} t={t} />
) : (
<WidgetGrid
cols={gridCols}
columns={state.cols}
gap={state.gap ?? (state.nested ? NESTED_GAP : undefined)}
minColumnWidth={1}
paddingX={state.paddingX ?? undefined}
rowGap={0}
widgets={widgets}
/>
)}
</Box>
{!state.zoomed && !state.streams && (
<Box marginTop={1}>
<Text color={t.color.muted} wrap="truncate">
gap {state.gap ?? 'auto'} · pad {state.paddingX ?? 'auto'} · nested {state.nested ? 'on' : 'off'} · areas{' '}
{state.areas ? 'on (2fr first col · c1 spans rows · c2 spans cols)' : 'off'}
</Text>
</Box>
)}
</Box>
)
}
/**
* Areas-mode demo: the same cols×rows surface driven by `GridAreas` — a
* weighted first column (2fr vs 1fr when there's room), `c1` spanning two
* rows, `c2` spanning two columns, everything else auto-placed dense. The
* cursor highlights whichever cell's row/col range contains it, so moving
* onto a spanned cell lights the whole merged area.
*/
function AreasDemo({ cols, state, t }: { cols: number; state: GridTestState; t: Theme }) {
const height = state.rows * FLAT_CELL_HEIGHT
const columnTracks: GridTrackSize[] =
state.cols >= 3
? [{ fr: 2 }, ...Array.from({ length: state.cols - 1 }, () => ({ fr: 1 }))]
: Array.from({ length: state.cols }, () => ({ fr: 1 }))
const rowSpanFirst = state.rows >= 2 ? 2 : 1
const colSpanSecond = state.cols >= 2 ? 2 : 1
const extraSlots = rowSpanFirst - 1 + (colSpanSecond - 1)
const itemCount = Math.max(1, state.rows * state.cols - extraSlots)
const cursorInside = (cell: GridAreaCell) =>
state.activeRow >= cell.row &&
state.activeRow < cell.row + cell.rowSpan &&
state.activeCol >= cell.col &&
state.activeCol < cell.col + cell.colSpan
const widgets: GridAreaWidget[] = Array.from({ length: itemCount }, (_, idx) => ({
colSpan: idx === 1 ? colSpanSecond : 1,
id: `area-c${idx + 1}`,
render: (cell: GridAreaCell) => (
<AreaDemoCell active={cursorInside(cell)} cell={cell} label={`c${idx + 1}`} t={t} />
),
rowSpan: idx === 0 ? rowSpanFirst : 1
}))
return (
<GridAreas
columns={columnTracks}
gap={state.gap ?? 1}
height={height}
rowGap={0}
rows={state.rows}
widgets={widgets}
width={cols}
/>
)
}
function AreaDemoCell({ active, cell, label, t }: { active: boolean; cell: GridAreaCell; label: string; t: Theme }) {
const borderColor = active ? t.color.primary : t.color.border
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
alignItems="center"
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={cell.height}
justifyContent="center"
width={cell.width}
>
<Text bold={active} color={labelColor}>
{label}
</Text>
{cell.height >= 5 && (
<Text color={t.color.muted}>
{cell.colSpan > 1 || cell.rowSpan > 1 ? `${cell.colSpan}x${cell.rowSpan}` : `${cell.width}w`}
</Text>
)}
</Box>
)
}
function GridCell({
active,
label,
nested,
nestedMode,
t,
width
}: {
active: boolean
label: string
nested: boolean
nestedMode: boolean
t: Theme
width: number
}) {
const padX = width >= 14 ? 1 : 0
const inner = Math.max(1, width - 2 - padX * 2)
const borderColor = active ? t.color.primary : t.color.border
const height = nestedMode ? NESTED_CELL_HEIGHT : FLAT_CELL_HEIGHT
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={height}
paddingX={padX}
width={width}
>
{nested && width >= 10 ? (
<>
<Box justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
<WidgetGrid
cols={inner}
columns={2}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, 3, 2)}
/>
</>
) : (
<Box alignItems="center" flexGrow={1} justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
)}
</Box>
)
}
function ZoomedGridCell({ cols, parentLabel, t }: { cols: number; parentLabel: string; t: Theme }) {
const childColumns = cols >= 72 ? 4 : 2
const contentWidth = Math.max(1, cols - 4)
return (
<Box
borderColor={t.color.primary}
borderStyle="round"
flexDirection="column"
paddingX={1}
paddingY={1}
width={cols}
>
<WidgetGrid
cols={contentWidth}
columns={2}
depth={1}
gap={1}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary}>
parent {parentLabel}
</Text>
),
id: 'header-title'
},
{
children: (
<Box justifyContent="flex-end" width="100%">
<Text color={t.color.muted}>nested child grid</Text>
</Box>
),
id: 'header-meta'
}
]}
/>
<Box height={1} />
<WidgetGrid
cols={contentWidth}
columns={childColumns}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, childColumns * 2, childColumns)}
/>
</Box>
)
}
const childCellWidgets = (t: Theme, count: number, columns: number): WidgetGridWidget[] => {
const colors = [t.color.ok, t.color.warn, t.color.accent, t.color.muted, t.color.primary]
// 3-cell preview: two side-by-side, third spans the full row.
const lastSpansRow = count === 3
return Array.from({ length: count }, (_, idx) => ({
colSpan: lastSpansRow && idx === count - 1 ? columns : 1,
id: `child-c${idx + 1}`,
render: w => <MiniCell color={colors[idx % colors.length]!} label={`c${idx + 1}`} width={w} />
}))
}
function MiniCell({ color, label, width }: { color: string; label: string; width: number }) {
return (
<Box
alignItems="center"
borderColor={color}
borderStyle="single"
height={MINI_CELL_HEIGHT}
justifyContent="center"
overflow="hidden"
width={width}
>
<Text color={color}>{label}</Text>
</Box>
)
}