feat(ui-tui): widget-grid 2-axis layout engine + overlay primitives (#20379 rebase)

Rebase of the widget-grid PR onto current main, then grow it into a real
2-axis engine: resolveGridTracks (grid-template tracks — fixed cell counts
and weighted fr shares with mins), layoutWidgetGrid (1D auto-packing flow),
and layoutGridAreas (2D absolute placement with row/col spans and implicit
row growth). Overlay/Dialog primitives give zoned viewport-level modals
with an optional scrim. /grid-test (interactive: areas, nesting, zoom,
gap/padding) and /grid-test streams (4x3 mission-control GridAreas board
with promote-to-main) exercise everything end to end.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:36:01 -05:00
parent 81f4095bc1
commit 58d75ec5c6
17 changed files with 2380 additions and 9 deletions

1
ui-tui/node_modules Symbolic link
View file

@ -0,0 +1 @@
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules

View file

@ -70,6 +70,22 @@ describe('createSlashHandler', () => {
expect(getOverlayState().sessions).toBe(true)
})
it('opens the grid-test overlay locally', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
expect(getOverlayState().gridTest).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('opens the grid-test streams demo via /grid-test streams', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true)
expect(getOverlayState().gridTest).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()

View file

@ -0,0 +1,246 @@
import { describe, expect, it } from 'vitest'
import { type GridTrackSize, layoutGridAreas, layoutWidgetGrid, resolveGridTracks } from '../lib/widgetGrid.js'
describe('layoutWidgetGrid', () => {
it('falls back to a single column on narrow widths', () => {
const layout = layoutWidgetGrid({
items: [{ id: 'a' }, { id: 'b' }],
maxColumns: 3,
minColumnWidth: 40,
width: 35
})
expect(layout.columnCount).toBe(1)
expect(layout.columns).toEqual([35])
expect(layout.rows).toEqual([[{ col: 0, id: 'a', span: 1, width: 35 }], [{ col: 0, id: 'b', span: 1, width: 35 }]])
})
it('packs spans left-to-right and wraps to the next row', () => {
const layout = layoutWidgetGrid({
gap: 2,
items: [
{ id: 'a', span: 1 },
{ id: 'b', span: 2 },
{ id: 'c', span: 1 }
],
maxColumns: 3,
minColumnWidth: 30,
width: 100
})
expect(layout.columnCount).toBe(3)
expect(layout.columns).toEqual([32, 32, 32])
expect(layout.rows).toEqual([
[
{ col: 0, id: 'a', span: 1, width: 32 },
{ col: 1, id: 'b', span: 2, width: 66 }
],
[{ col: 0, id: 'c', span: 1, width: 32 }]
])
})
it('clamps spans to available columns', () => {
const layout = layoutWidgetGrid({
gap: 1,
items: [{ id: 'huge', span: 9 }],
maxColumns: 2,
minColumnWidth: 20,
width: 50
})
expect(layout.columnCount).toBe(2)
expect(layout.rows[0]?.[0]).toEqual({
col: 0,
id: 'huge',
span: 2,
width: 50
})
})
it('honors an exact column count when the grid has room', () => {
const layout = layoutWidgetGrid({
columns: 4,
gap: 1,
items: Array.from({ length: 8 }, (_, idx) => ({ id: `cell-${idx}` })),
width: 43
})
expect(layout.columnCount).toBe(4)
expect(layout.columns).toEqual([10, 10, 10, 10])
expect(layout.rows).toHaveLength(2)
})
it('renders sparse explicit starts without collapsing holes', () => {
const layout = layoutWidgetGrid({
columns: 4,
gap: 1,
items: [
{ colStart: 0, id: 'a' },
{ colStart: 2, id: 'b' },
{ colStart: 3, id: 'c' }
],
width: 43
})
expect(layout.rows).toEqual([
[
{ col: 0, id: 'a', span: 1, width: 10 },
{ col: 2, id: 'b', span: 1, width: 10 },
{ col: 3, id: 'c', span: 1, width: 10 }
]
])
})
})
describe('resolveGridTracks', () => {
it('splits equal fr tracks with the remainder spread left-to-right', () => {
expect(resolveGridTracks(10, 0, [{ fr: 1 }, { fr: 1 }, { fr: 1 }])).toEqual([4, 3, 3])
})
it('gives fixed tracks their size and shares the leftover by weight', () => {
// usable = 40 - 2*1 = 38; fixed 10; leftover 28 over 1fr+2fr.
expect(resolveGridTracks(40, 1, [10, { fr: 1 }, { fr: 2 }])).toEqual([10, 10, 18])
})
it('pins tracks that fall below their min and re-solves the rest', () => {
expect(resolveGridTracks(20, 0, [{ fr: 1 }, { fr: 1, min: 15 }])).toEqual([5, 15])
})
it('never lets the sum exceed the usable axis when tracks fit at their minimums', () => {
const cases: Array<{ gap: number; total: number; tracks: GridTrackSize[] }> = [
{ gap: 1, total: 80, tracks: [12, { fr: 1 }, { fr: 3, min: 20 }, 6] },
{ gap: 2, total: 33, tracks: [{ fr: 1 }, { fr: 1 }, { fr: 1 }, { fr: 1 }] },
{ gap: 0, total: 9, tracks: [4, 4, { fr: 1 }] }
]
for (const { gap, total, tracks } of cases) {
const sizes = resolveGridTracks(total, gap, tracks)
const usable = total - gap * (tracks.length - 1)
expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(Math.max(tracks.length, usable))
expect(sizes.every(s => s >= 1)).toBe(true)
}
})
it('shaves overflow off trailing tracks when fixed sizes exceed the axis', () => {
const sizes = resolveGridTracks(20, 0, [15, 15, { fr: 1 }])
expect(sizes.reduce((acc, s) => acc + s, 0)).toBeLessThanOrEqual(20)
expect(sizes[0]).toBe(15)
expect(sizes.every(s => s >= 1)).toBe(true)
})
})
describe('layoutGridAreas', () => {
it('solves a rowSpan cell so it occupies both rows and blocks placement beneath it', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 10,
items: [{ id: 'tall', rowSpan: 2 }, { id: 'b' }, { id: 'c' }],
rowGap: 0,
width: 20
})
const [tall, b, c] = layout.cells
expect(tall).toMatchObject({ col: 0, height: 10, row: 0, rowSpan: 2, width: 10, x: 0, y: 0 })
expect(b).toMatchObject({ col: 1, row: 0, x: 10, y: 0 })
// c cannot go under `tall` — it lands in the second row's free column.
expect(c).toMatchObject({ col: 1, row: 1, x: 10, y: 5 })
})
it('fills holes densely (auto-flow dense) after a colSpan pushes a wrap', () => {
const layout = layoutGridAreas({
columns: 3,
gap: 0,
height: 6,
items: [{ id: 'a' }, { colSpan: 2, id: 'wide' }, { id: 'filler' }],
width: 30
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
expect(byId['a']).toMatchObject({ col: 0, row: 0 })
expect(byId['wide']).toMatchObject({ col: 1, colSpan: 2, row: 0 })
expect(byId['filler']).toMatchObject({ col: 0, row: 1 })
})
it('spans include the gaps they bridge', () => {
const layout = layoutGridAreas({
columns: [{ fr: 1 }, { fr: 1 }, { fr: 1 }],
gap: 2,
height: 11,
items: [{ colSpan: 3, id: 'full' }, { id: 'a' }, { id: 'b' }, { rowSpan: 2, id: 'tall' }],
rowGap: 1,
width: 32
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
// 3 tracks over usable 28 → [10, 9, 9]; full spans all three + two gaps.
expect(layout.columnSizes).toEqual([10, 9, 9])
expect(byId['full']!.width).toBe(32)
// 3 rows over usable 9 → [3, 3, 3]; tall spans rows 2-3 + one rowGap.
expect(layout.rowSizes).toEqual([3, 3, 3])
expect(byId['tall']!.height).toBe(7)
expect(byId['tall']!.y).toBe(4)
})
it('honors explicit col/row pins and grows implicit rows beneath explicit tracks', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 12,
items: [
{ col: 1, id: 'pinned', row: 2 },
{ id: 'auto-a' },
{ id: 'auto-b' }
],
rowGap: 0,
rows: 2,
width: 10
})
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
// The pin forces a third row beyond the two explicit tracks.
expect(layout.rowCount).toBe(3)
expect(byId['pinned']).toMatchObject({ col: 1, row: 2 })
expect(byId['auto-a']).toMatchObject({ col: 0, row: 0 })
expect(byId['auto-b']).toMatchObject({ col: 1, row: 0 })
})
it('supports weighted and fixed row tracks', () => {
const layout = layoutGridAreas({
columns: 1,
gap: 0,
height: 20,
items: [{ id: 'header' }, { id: 'body' }, { id: 'footer' }],
rowGap: 0,
rows: [3, { fr: 1 }, 3],
width: 40
})
expect(layout.rowSizes).toEqual([3, 14, 3])
const byId = Object.fromEntries(layout.cells.map(cell => [cell.id, cell]))
expect(byId['header']!.height).toBe(3)
expect(byId['body']).toMatchObject({ height: 14, y: 3 })
expect(byId['footer']).toMatchObject({ height: 3, y: 17 })
})
it('clamps colSpan to the column count', () => {
const layout = layoutGridAreas({
columns: 2,
gap: 0,
height: 4,
items: [{ colSpan: 9, id: 'huge' }],
width: 10
})
expect(layout.cells[0]).toMatchObject({ col: 0, colSpan: 2, width: 10 })
})
})

View file

@ -0,0 +1,143 @@
import { PassThrough } from 'stream'
import { renderSync, Text } from '@hermes/ink'
import React, { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { GRID_STREAM_COUNT, type GridTestState } from '../app/interfaces.js'
import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js'
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js'
import { stripAnsi } from '../lib/text.js'
import { DEFAULT_THEME } from '../theme.js'
function StatefulCell({ label }: { label: string }) {
const [value] = useState(label)
return <Text>{value}</Text>
}
const renderToText = (node: React.ReactElement) => {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
Object.assign(stdout, { columns: 100, isTTY: false, rows: 24 })
Object.assign(stdin, { isTTY: false })
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(node, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
const renderGrid = (widgets: WidgetGridWidget[]) =>
renderToText(<WidgetGrid cols={80} columns={2} gap={1} paddingX={0} widgets={widgets} />)
describe('WidgetGrid component composition', () => {
it('renders stateful direct children and nested grids inside cells', () => {
const output = renderGrid([
{
children: <StatefulCell label="stateful-c1" />,
id: 'stateful'
},
{
children: (
<WidgetGrid
cols={38}
columns={2}
gap={1}
paddingX={0}
widgets={[
{ children: <StatefulCell label="nested-c1" />, id: 'nested-c1' },
{ render: () => <StatefulCell label="nested-c2" />, id: 'nested-c2' }
]}
/>
),
id: 'nested-grid'
}
])
expect(output).toContain('stateful-c1')
expect(output).toContain('nested-c1')
expect(output).toContain('nested-c2')
})
})
describe('GridAreas component', () => {
it('renders a rowSpan cell alongside stacked cells at their solved rects', () => {
const widgets: GridAreaWidget[] = [
{ id: 'tall', render: cell => <Text>{`tall ${cell.width}x${cell.height}`}</Text>, rowSpan: 2 },
{ children: <Text>top-right</Text>, id: 'b' },
{ children: cell => <Text>{`bottom-right y${cell.y}`}</Text>, id: 'c' }
]
const output = renderToText(<GridAreas columns={2} gap={0} height={6} rowGap={0} widgets={widgets} width={40} />)
// tall spans both rows of the 6-row grid at 20 cells wide.
expect(output).toContain('tall 20x6')
expect(output).toContain('top-right')
expect(output).toContain('bottom-right y3')
})
it('gives fixed header/footer rows their size and the fr body the rest', () => {
const widgets: GridAreaWidget[] = [
{ children: cell => <Text>{`header h${cell.height}`}</Text>, id: 'header' },
{ children: cell => <Text>{`body h${cell.height}`}</Text>, id: 'body' },
{ children: cell => <Text>{`footer h${cell.height}`}</Text>, id: 'footer' }
]
const output = renderToText(
<GridAreas columns={1} gap={0} height={12} rowGap={0} rows={[1, { fr: 1 }, 1]} widgets={widgets} width={30} />
)
expect(output).toContain('header h1')
expect(output).toContain('body h10')
expect(output).toContain('footer h1')
})
})
describe('GridStreamsDemo', () => {
const streamsState: GridTestState = {
activeCol: 0,
activeRow: 0,
areas: false,
cols: 4,
gap: null,
nested: false,
paddingX: null,
rows: 3,
streamFocus: 1,
streamMain: 2,
streams: true,
zoomed: false
}
it('keeps the panel count in lockstep with the input handler focus wrap', () => {
expect(STREAM_DEFS.length).toBe(GRID_STREAM_COUNT)
})
it('renders every stream panel with the promoted panel in the header', () => {
const output = renderToText(<GridStreamsDemo cols={90} state={streamsState} t={DEFAULT_THEME} />)
expect(output).toContain('hermes mission control')
for (const def of STREAM_DEFS) {
expect(output).toContain(def.title)
}
// streamMain: 2 → the memory panel owns the promoted slot.
expect(output).toContain('main: memory')
})
})

View file

@ -283,6 +283,8 @@ export interface OverlayState {
billing: BillingOverlayState | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
dialog: DialogState | null
gridTest: GridTestState | null
journey: boolean
modelPicker: boolean | { refresh?: boolean }
pager: null | PagerState
@ -295,12 +297,41 @@ export interface OverlayState {
sudo: null | SudoReq
}
export interface DialogState {
body: string
hint?: string
title?: string
zone?: 'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right'
}
export interface PagerState {
lines: string[]
offset: number
title?: string
}
/** Number of live panels in the /grid-test streams demo (focus wraps mod this). */
export const GRID_STREAM_COUNT = 6
export interface GridTestState {
activeCol: number
activeRow: number
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
areas: boolean
cols: number
gap: null | number
nested: boolean
paddingX: null | number
rows: number
/** Streams mode: live-updating panels tiled by GridAreas. */
streams: boolean
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
streamFocus: number
/** Streams mode: which panel owns the promoted 2x2 slot. */
streamMain: number
zoomed: boolean
}
export interface TranscriptRow {
index: number
key: string

View file

@ -9,6 +9,8 @@ const buildOverlayState = (): OverlayState => ({
billing: null,
clarify: null,
confirm: null,
dialog: null,
gridTest: null,
journey: false,
modelPicker: false,
pager: null,
@ -31,6 +33,8 @@ export const $isBlocked = computed(
billing,
clarify,
confirm,
dialog,
gridTest,
journey,
modelPicker,
pager,
@ -48,6 +52,8 @@ export const $isBlocked = computed(
billing ||
clarify ||
confirm ||
dialog ||
gridTest ||
journey ||
modelPicker ||
pager ||
@ -82,6 +88,8 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
dialog: $overlayState.get().dialog,
gridTest: $overlayState.get().gridTest,
journey: $overlayState.get().journey,
modelPicker: $overlayState.get().modelPicker,
petPicker: $overlayState.get().petPicker,

View file

@ -107,7 +107,9 @@ export const coreCommands: SlashCommand[] = [
'/details <section> [hidden|collapsed|expanded|reset]',
'override one section (thinking/tools/subagents/activity)'
],
['/fortune [random|daily]', 'show a random or daily local fortune']
['/fortune [random|daily]', 'show a random or daily local fortune'],
['/grid-test [cols]x[rows]', 'open the interactive widget-grid demo'],
['/dialog-test [zone]', 'open a sample dialog overlay with a faked backdrop']
],
title: 'TUI'
},

View file

@ -1,7 +1,113 @@
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
import type { DialogState } from '../../interfaces.js'
import { patchOverlayState } from '../../overlayStore.js'
import type { SlashCommand } from '../types.js'
const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
const GRID_TEST_MAX_SIZE = 12
const DIALOG_TEST_ZONES = new Set<DialogState['zone']>([
'bottom',
'bottom-left',
'bottom-right',
'center',
'left',
'right',
'top',
'top-left',
'top-right'
])
const DIALOG_TEST_USAGE = `usage: /dialog-test [zone] zones: ${[...DIALOG_TEST_ZONES].join(', ')}`
const clampGridSize = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.max(1, Math.min(GRID_TEST_MAX_SIZE, Math.trunc(value)))
}
const parseGridTestSize = (arg: string) => {
const trimmed = arg.trim()
if (!trimmed) {
return { cols: 4, rows: 3 }
}
const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i)
if (grid) {
return { cols: clampGridSize(Number(grid[1]), 4), rows: clampGridSize(Number(grid[2]), 3) }
}
const [cols, rows, ...rest] = trimmed.split(/\s+/)
if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) {
return null
}
return { cols: clampGridSize(Number(cols), 4), rows: clampGridSize(Number(rows), 3) }
}
export const debugCommands: SlashCommand[] = [
{
help: 'open an interactive widget-grid demo overlay',
name: 'grid-test',
run: (arg, ctx) => {
const streams = arg.trim().toLowerCase() === 'streams'
const size = streams ? { cols: 4, rows: 3 } : parseGridTestSize(arg)
if (!size) {
return ctx.transcript.sys(GRID_TEST_USAGE)
}
patchOverlayState({
gridTest: {
activeCol: 0,
activeRow: 0,
areas: false,
cols: size.cols,
gap: null,
nested: false,
paddingX: null,
rows: size.rows,
streamFocus: 0,
streamMain: 0,
streams,
zoomed: false
}
})
}
},
{
help: 'open a sample dialog overlay with a faked backdrop',
name: 'dialog-test',
run: (arg, ctx) => {
const trimmed = arg.trim().toLowerCase()
const zone = (trimmed || 'center') as DialogState['zone']
if (!DIALOG_TEST_ZONES.has(zone)) {
return ctx.transcript.sys(DIALOG_TEST_USAGE)
}
patchOverlayState({
dialog: {
body: [
'This is a viewport-level overlay with a backdrop.',
'',
`Zone: ${zone}`,
'Try: /dialog-test top-right · bottom · left · ...'
].join('\n'),
hint: 'Esc/q/Enter close · Ctrl+C close',
title: 'Dialog primitive',
zone
}
})
}
},
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',

View file

@ -16,12 +16,14 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
import type {
GatewayRpc,
InputHandlerActions,
InputHandlerContext,
InputHandlerResult,
OverlayState
import {
type GatewayRpc,
GRID_STREAM_COUNT,
type GridTestState,
type InputHandlerActions,
type InputHandlerContext,
type InputHandlerResult,
type OverlayState
} from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
@ -127,6 +129,23 @@ export function dismissSensitivePrompt(
}
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const GRID_TEST_MAX_SIZE = 12
const cycleAutoNumber = (value: null | number, max: number) => {
if (value === null) {
return 0
}
return value >= max ? null : value + 1
}
const keepGridCursorInBounds = (grid: GridTestState): GridTestState => ({
...grid,
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
})
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
const { actions: cActions, refs: cRefs, state: cState } = composer
@ -218,6 +237,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.journey) {
return patchOverlayState({ journey: false })
}
if (overlay.gridTest) {
return patchOverlayState({ gridTest: null })
}
if (overlay.dialog) {
return patchOverlayState({ dialog: null })
}
}
const cycleQueue = (dir: 1 | -1) => {
@ -401,6 +428,159 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return
}
if (overlay.gridTest) {
const updateGrid = (fn: (grid: GridTestState) => GridTestState) =>
patchOverlayState(prev =>
prev.gridTest ? { ...prev, gridTest: keepGridCursorInBounds(fn(prev.gridTest)) } : prev
)
const openDemoDialog = () =>
patchOverlayState({
dialog: {
body: ['Dialog overlaid on top of /grid-test.', '', 'Backdrop dims the grid behind.'].join('\n'),
hint: 'Esc/q/Enter close',
title: 'Overlay primitive',
zone: 'center'
}
})
const resetGrid = () =>
updateGrid(grid => ({
...grid,
activeCol: 0,
activeRow: 0,
areas: false,
cols: 4,
gap: null,
nested: false,
paddingX: null,
rows: 3,
streamFocus: 0,
streamMain: 0,
streams: false,
zoomed: false
}))
if (isCtrl(key, ch, 'c')) {
return patchOverlayState({ gridTest: null })
}
// Streams mode swallows the grid-shape keys: focus cycles across the
// live panels and Enter promotes the focused one to the 2x2 slot.
if (overlay.gridTest.streams) {
if (key.escape || ch === 'q' || ch === 's') {
return updateGrid(grid => ({ ...grid, streams: false }))
}
if (key.return) {
return updateGrid(grid => ({ ...grid, streamMain: grid.streamFocus }))
}
if (ch === 'd') {
return openDemoDialog()
}
if (ch === 'r') {
return resetGrid()
}
if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
return updateGrid(grid => ({
...grid,
streamFocus: (grid.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT
}))
}
if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
return updateGrid(grid => ({ ...grid, streamFocus: (grid.streamFocus + 1) % GRID_STREAM_COUNT }))
}
return
}
if (overlay.gridTest.zoomed && (key.escape || ch === 'q')) {
return updateGrid(grid => ({ ...grid, zoomed: false }))
}
if (key.escape || ch === 'q') {
return patchOverlayState({ gridTest: null })
}
if (key.return) {
return updateGrid(grid => ({ ...grid, nested: true, zoomed: true }))
}
if (ch === 'n') {
return updateGrid(grid => ({ ...grid, nested: !grid.nested }))
}
if (ch === 'a') {
return updateGrid(grid => ({ ...grid, areas: !grid.areas, streams: false }))
}
if (ch === 's') {
return updateGrid(grid => ({ ...grid, areas: false, streams: true }))
}
if (ch === 'g') {
return updateGrid(grid => ({ ...grid, gap: cycleAutoNumber(grid.gap, 3) }))
}
if (ch === 'p') {
return updateGrid(grid => ({ ...grid, paddingX: cycleAutoNumber(grid.paddingX, 2) }))
}
if (ch === 'd') {
return openDemoDialog()
}
if (ch === 'r') {
return resetGrid()
}
if (ch === '+' || ch === '=') {
return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols + 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === '-' || ch === '_') {
return updateGrid(grid => ({ ...grid, cols: clamp(grid.cols - 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === ']') {
return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows + 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (ch === '[') {
return updateGrid(grid => ({ ...grid, rows: clamp(grid.rows - 1, 1, GRID_TEST_MAX_SIZE) }))
}
if (key.leftArrow || ch === 'h') {
return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol - 1, 0, grid.cols - 1) }))
}
if (key.rightArrow || ch === 'l') {
return updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol + 1, 0, grid.cols - 1) }))
}
if (key.upArrow || ch === 'k') {
return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow - 1, 0, grid.rows - 1) }))
}
if (key.downArrow || ch === 'j') {
return updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow + 1, 0, grid.rows - 1) }))
}
return
}
if (overlay.dialog) {
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q' || key.return) {
return patchOverlayState({ dialog: null })
}
return
}
if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.sessions) {

View file

@ -28,6 +28,7 @@ import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { Journey } from './journey.js'
import { MessageLine } from './messageLine.js'
import { Dialog, Overlay } from './overlay.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
@ -560,6 +561,20 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && <PetPane />}
</Box>
{overlay.dialog && (
<Overlay backdrop zone={overlay.dialog.zone ?? 'center'}>
<Dialog
hint={overlay.dialog.hint ?? 'Esc/q close'}
title={overlay.dialog.title}
width={Math.min(60, composer.cols - 8)}
>
{overlay.dialog.body.split('\n').map((line, i) => (
<Text key={i}>{line || ' '}</Text>
))}
</Dialog>
</Overlay>
)}
</Shell>
)
})

View file

@ -9,6 +9,7 @@ import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
import { FloatBox } from './appChrome.js'
import { BillingOverlay } from './billingOverlay.js'
import { GridTestOverlay } from './gridTestOverlay.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
@ -157,6 +158,7 @@ export function FloatingOverlays({
const theme = useStore($uiTheme)
const hasAny =
overlay.gridTest ||
overlay.modelPicker ||
overlay.pager ||
overlay.petPicker ||
@ -178,6 +180,12 @@ export function FloatingOverlays({
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.gridTest && (
<FloatBox color={theme.color.border}>
<GridTestOverlay cols={Math.max(24, cols - 6)} state={overlay.gridTest} t={theme} />
</FloatBox>
)}
{overlay.sessions && (
<FloatBox color={theme.color.border}>
<ActiveSessionSwitcher

View file

@ -0,0 +1,393 @@
import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
import type { GridTestState } from '../app/interfaces.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.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
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
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
}
/**
* Render history as a column chart `rows` lines tall (top line first). Each
* column gets `rows * 8` vertical levels full blocks below the value, a
* partial eighth-block at the value row, spaces above so a promoted (taller)
* cell genuinely gains chart resolution rather than just whitespace.
*/
const sparkRows = (history: number[], width: number, rows: number): string[] => {
const window = history.slice(-Math.max(1, width))
if (!window.length) {
return Array.from({ length: rows }, () => '')
}
const min = Math.min(...window)
const max = Math.max(...window)
const range = max - min || 1
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
return Array.from({ length: rows }, (_, lineIdx) => {
const rowFromBottom = rows - 1 - lineIdx
return levels
.map(level => {
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
return filled === 0 ? ' ' : SPARK_CHARS[filled - 1]
})
.join('')
})
}
// ── 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}
>
<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}
/>
)
})

View file

@ -0,0 +1,316 @@
import { Box, Text } from '@hermes/ink'
import type { GridTestState } from '../app/interfaces.js'
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.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>
)
}

View file

@ -0,0 +1,141 @@
import { Box, Text, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { type ReactNode } from 'react'
import { $uiTheme } from '../app/uiStore.js'
export type OverlayZone =
| 'bottom'
| 'bottom-left'
| 'bottom-right'
| 'center'
| 'left'
| 'right'
| 'top'
| 'top-left'
| 'top-right'
interface OverlayProps {
/** Render a faux scrim behind the content (lipgloss-style: spaces + bg color). */
backdrop?: boolean
/** Background color used to paint the scrim. Defaults to `theme.color.statusBg`. */
backdropColor?: string
children: ReactNode
/** Nine CSS-grid-style zones. Defaults to `center`. */
zone?: OverlayZone
}
/**
* Viewport-level overlay primitive. Positions its child in one of nine zones
* and optionally paints a scrim behind it.
*
* Backdrop uses the canonical TUI pattern (cf. `lipgloss.Place`): each cell is
* a SPACE with a backgroundColor, so the area reads as a clean dimmed plane
* over the transcript. Ink only paints `backgroundColor` on cells with
* content, so the scrim is rendered as explicit lines of spaces a `<Box>`
* with a bg alone would be invisible. Uses stdout dims so placement is
* deterministic regardless of tree depth.
*/
export function Overlay({ backdrop = false, backdropColor, children, zone = 'center' }: OverlayProps) {
const { stdout } = useStdout()
const theme = useStore($uiTheme)
const cols = stdout?.columns ?? 80
const rows = stdout?.rows ?? 24
const [justify, align] = zoneFlex(zone)
const scrimBg = backdropColor ?? theme.color.statusBg
const scrimLine = ' '.repeat(cols)
return (
<>
{backdrop && (
<Box flexDirection="column" height={rows} left={0} position="absolute" top={0} width={cols}>
{Array.from({ length: rows }, (_, i) => (
<Text backgroundColor={scrimBg} key={i}>
{scrimLine}
</Text>
))}
</Box>
)}
<Box
alignItems={align}
flexDirection="row"
height={rows}
justifyContent={justify}
left={0}
position="absolute"
top={0}
width={cols}
>
{children}
</Box>
</>
)
}
interface DialogProps {
children: ReactNode
hint?: ReactNode
title?: string
width?: number
}
/** Bordered card with optional title + hint. Pair with `Overlay` for centered modals. */
export function Dialog({ children, hint, title, width }: DialogProps) {
const theme = useStore($uiTheme)
const innerWidth = width !== undefined ? Math.max(1, width - 6) : undefined
return (
<Box
borderColor={theme.color.primary}
borderStyle="round"
flexDirection="column"
opaque
paddingX={2}
paddingY={1}
width={width}
>
{title && (
<Box justifyContent="center" marginBottom={1} width={innerWidth}>
<Text bold color={theme.color.primary}>
{title}
</Text>
</Box>
)}
{children}
{hint && (
<Box marginTop={1}>{typeof hint === 'string' ? <Text color={theme.color.muted}>{hint}</Text> : hint}</Box>
)}
</Box>
)
}
const zoneFlex = (zone: OverlayZone): ['center' | 'flex-end' | 'flex-start', 'center' | 'flex-end' | 'flex-start'] => {
const horizontal = {
bottom: 'center',
'bottom-left': 'flex-start',
'bottom-right': 'flex-end',
center: 'center',
left: 'flex-start',
right: 'flex-end',
top: 'center',
'top-left': 'flex-start',
'top-right': 'flex-end'
} as const
const vertical = {
bottom: 'flex-end',
'bottom-left': 'flex-end',
'bottom-right': 'flex-end',
center: 'center',
left: 'center',
right: 'center',
top: 'flex-start',
'top-left': 'flex-start',
'top-right': 'flex-start'
} as const
return [horizontal[zone], vertical[zone]]
}

View file

@ -11,7 +11,7 @@ const VISIBLE = 12
const MIN_WIDTH = 40
const MAX_WIDTH = 90
export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
const [skillsByCat, setSkillsByCat] = useState<Record<string, string[]>>({})
const [selectedCat, setSelectedCat] = useState('')
const [catIdx, setCatIdx] = useState(0)
@ -23,7 +23,10 @@ export function SkillsHub({ gw, onClose, t }: SkillsHubProps) {
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
const terminalWidth = Math.max(1, (stdout?.columns ?? 80) - 6)
const preferredWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, terminalWidth))
const widthCap = Math.max(24, Math.trunc(maxWidth ?? preferredWidth))
const width = Math.max(24, Math.min(preferredWidth, widthCap))
useEffect(() => {
gw.request<{ skills?: Record<string, string[]> }>('skills.manage', { action: 'list' })
@ -303,6 +306,7 @@ interface SkillInfo {
interface SkillsHubProps {
gw: GatewayClient
maxWidth?: number
onClose: () => void
t: Theme
}

View file

@ -0,0 +1,266 @@
import { Box } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import {
type GridAreaCell,
type GridAreaItem,
type GridTrackSize,
layoutGridAreas,
layoutWidgetGrid,
type WidgetGridCell,
type WidgetGridItem,
widgetGridSpanWidth
} from '../lib/widgetGrid.js'
export interface WidgetGridRenderContext {
cell: WidgetGridCell
width: number
}
type WidgetGridChildren = ((ctx: WidgetGridRenderContext) => ReactNode) | ReactNode
/**
* A grid item with optional content. Use `children` for static or stateful
* React subtrees (including a nested `WidgetGrid`) and `render` for a width-
* aware factory; if both are provided, `render` wins.
*/
export interface WidgetGridWidget extends WidgetGridItem {
children?: WidgetGridChildren
render?: (width: number, cell: WidgetGridCell) => ReactNode
}
/**
* `WidgetGrid` lays out children into rows/cols using the same primitives as
* CSS grid: explicit `columns` count or a width-derived auto count, per-item
* `colStart` / `colSpan`, and uniform `gap` / `rowGap`. Cells clip their
* contents (`overflow: hidden`) so child overflow can never bleed into the
* neighbouring cell or break the parent border.
*/
interface WidgetGridProps {
columns?: number
cols: number
depth?: number
gap?: number
maxColumns?: number
minColumnWidth?: number
paddingX?: number
paddingY?: number
rowGap?: number
widgets: WidgetGridWidget[]
}
const toInt = (value: number, fallback: number) => (Number.isFinite(value) ? Math.trunc(value) : fallback)
const inferredGap = (cols: number, columns: number | undefined, depth: number) => {
if (cols < 36 || (columns ?? 0) >= 8) {
return 0
}
if (depth > 0 || cols < 72 || (columns ?? 0) >= 4) {
return 1
}
return 2
}
const inferredPaddingX = (cols: number, depth: number) => {
if (depth <= 0 || cols < 24) {
return 0
}
return cols >= 56 ? 2 : 1
}
const inferredRowGap = (depth: number) => (depth > 0 ? 0 : 1)
export const WidgetGrid = memo(function WidgetGrid({
columns,
cols,
depth = 0,
gap,
maxColumns = 2,
minColumnWidth = 46,
paddingX,
paddingY,
rowGap,
widgets
}: WidgetGridProps) {
const safeCols = Math.max(1, toInt(cols, 1))
const safePaddingX = Math.max(0, toInt(paddingX ?? inferredPaddingX(safeCols, depth), 0))
const safePaddingY = Math.max(0, toInt(paddingY ?? 0, 0))
const innerCols = Math.max(1, safeCols - safePaddingX * 2)
const safeGap = Math.max(0, toInt(gap ?? inferredGap(innerCols, columns, depth), 0))
const safeRowGap = Math.max(0, toInt(rowGap ?? inferredRowGap(depth), 0))
const layout = useMemo(
() =>
layoutWidgetGrid({
columns,
gap: safeGap,
items: widgets.map(({ colSpan, colStart, id, span }) => ({ colSpan, colStart, id, span })),
maxColumns,
minColumnWidth,
width: innerCols
}),
[columns, innerCols, maxColumns, minColumnWidth, safeGap, widgets]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.rows.length) {
return null
}
return (
<Box flexDirection="column" paddingX={safePaddingX} paddingY={safePaddingY} width={safeCols}>
{layout.rows.map((row, rowIdx) => (
<Box flexDirection="column" key={`row-${rowIdx}`}>
<Box flexDirection="row">
<WidgetRow cells={row} columns={layout.columns} gap={safeGap} widgetById={widgetById} />
</Box>
{safeRowGap > 0 && rowIdx < layout.rows.length - 1 ? <Box height={safeRowGap} /> : null}
</Box>
))}
</Box>
)
})
const WidgetRow = memo(function WidgetRow({
cells,
columns,
gap,
widgetById
}: {
cells: WidgetGridCell[]
columns: number[]
gap: number
widgetById: Map<string, WidgetGridWidget>
}) {
return (
<>
{cells.map((cell, idx) => {
const cursor = idx === 0 ? 0 : cells[idx - 1]!.col + cells[idx - 1]!.span
const spacerWidth =
cell.col === 0
? 0
: cursor === 0
? widgetGridSpanWidth(columns, 0, cell.col, gap) + gap
: gap + (cell.col > cursor ? widgetGridSpanWidth(columns, cursor, cell.col - cursor, gap) + gap : 0)
return (
<Fragment key={cell.id}>
{spacerWidth > 0 ? <Box flexShrink={0} width={spacerWidth} /> : null}
<WidgetCell cell={cell} widget={widgetById.get(cell.id)} />
</Fragment>
)
})}
</>
)
})
const WidgetCell = memo(function WidgetCell({ cell, widget }: { cell: WidgetGridCell; widget?: WidgetGridWidget }) {
const node =
widget?.render?.(cell.width, cell) ??
(typeof widget?.children === 'function' ? widget.children({ cell, width: cell.width }) : widget?.children) ??
null
return (
<Box flexShrink={0} overflow="hidden" width={cell.width}>
{node}
</Box>
)
})
// ── GridAreas: the two-axis workspace mode ──────────────────────────────────
type GridAreaChildren = ((cell: GridAreaCell) => ReactNode) | ReactNode
/**
* An area widget: placement (`col`/`row`/`colSpan`/`rowSpan`) plus content.
* `render` receives the solved cell (with `width`/`height` in terminal
* cells); `children` accepts a static subtree or a factory. `render` wins
* when both are given, mirroring `WidgetGridWidget`.
*/
export interface GridAreaWidget extends GridAreaItem {
children?: GridAreaChildren
render?: (cell: GridAreaCell) => ReactNode
}
interface GridAreasProps {
/** Column tracks: a count (equal shares) or explicit fixed/`fr` tracks. */
columns: GridTrackSize[] | number
gap?: number
height: number
rowGap?: number
/** Row tracks. Omitted: every row is an equal `fr` share of `height`. */
rows?: GridTrackSize[] | number
widgets: GridAreaWidget[]
width: number
}
/**
* `GridAreas` renders widgets into a fully two-dimensional grid: explicit
* column AND row tracks (fixed cells or weighted `fr` shares), `colSpan` /
* `rowSpan`, `col` / `row` pins, and dense auto-placement. Unlike the flowing
* `WidgetGrid` (which stacks rows and cannot express `rowSpan`), every cell
* here is solved to a rect and absolutely positioned inside a fixed-size box,
* so a cell can span rows the same way a merged FancyZones cell does on the
* desktop app. Requires a known `height`.
*/
export const GridAreas = memo(function GridAreas({
columns,
gap = 1,
height,
rowGap = 0,
rows,
widgets,
width
}: GridAreasProps) {
const layout = useMemo(
() =>
layoutGridAreas({
columns,
gap,
height,
items: widgets.map(({ col, colSpan, id, row, rowSpan }) => ({ col, colSpan, id, row, rowSpan })),
rowGap,
rows,
width
}),
[columns, gap, height, rowGap, rows, widgets, width]
)
const widgetById = useMemo(() => new Map(widgets.map(widget => [widget.id, widget])), [widgets])
if (!layout.cells.length) {
return null
}
return (
<Box flexDirection="column" height={layout.height} overflow="hidden" width={layout.width}>
{layout.cells.map(cell => (
<AreaCell cell={cell} key={cell.id} widget={widgetById.get(cell.id)} />
))}
</Box>
)
})
const AreaCell = memo(function AreaCell({ cell, widget }: { cell: GridAreaCell; widget?: GridAreaWidget }) {
const node =
widget?.render?.(cell) ?? (typeof widget?.children === 'function' ? widget.children(cell) : widget?.children) ?? null
return (
<Box
height={cell.height}
left={cell.x}
overflow="hidden"
position="absolute"
top={cell.y}
width={cell.width}
>
{node}
</Box>
)
})

View file

@ -0,0 +1,495 @@
export interface WidgetGridItem {
colSpan?: number
colStart?: number
id: string
span?: number
}
export interface WidgetGridCell {
col: number
id: string
span: number
width: number
}
export interface WidgetGridLayout {
columnCount: number
columns: number[]
rows: WidgetGridCell[][]
}
export interface WidgetGridLayoutOptions {
columns?: number
gap?: number
items: WidgetGridItem[]
maxColumns?: number
minColumnWidth?: number
width: number
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const toInt = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.trunc(value)
}
const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const safeMaxColumns = Math.max(1, toInt(maxColumns, 1))
const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap))
return clamp(count || 1, 1, safeMaxColumns)
}
const buildColumnWidths = (width: number, columnCount: number, gap: number) =>
resolveGridTracks(width, gap, Array.from({ length: Math.max(1, toInt(columnCount, 1)) }, () => ({ fr: 1 })))
const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => {
const end = Math.min(columns.length, colStart + span)
const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0)
const safeGap = Math.max(0, toInt(gap, 0))
return width + safeGap * Math.max(0, end - colStart - 1)
}
export const widgetGridSpanWidth = spanWidth
const itemSpan = (item: WidgetGridItem, columnCount: number) =>
clamp(toInt(item.colSpan ?? item.span ?? 1, 1), 1, columnCount)
const itemColStart = (item: WidgetGridItem, columnCount: number, span: number) => {
if (item.colStart === undefined) {
return null
}
return clamp(toInt(item.colStart, 0), 0, Math.max(0, columnCount - span))
}
const rangeIsFree = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
if (occupied[col]) {
return false
}
}
return true
}
const occupyRange = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
occupied[col] = true
}
}
const firstFreeCol = (occupied: boolean[], span: number) => {
for (let col = 0; col <= occupied.length - span; col++) {
if (rangeIsFree(occupied, col, span)) {
return col
}
}
return null
}
const sortRow = (row: WidgetGridCell[]) => row.sort((a, b) => a.col - b.col)
export function layoutWidgetGrid({
columns: requestedColumns,
gap = 1,
items,
maxColumns = 3,
minColumnWidth = 28,
width
}: WidgetGridLayoutOptions): WidgetGridLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeWidth = Math.max(1, toInt(width, 1))
const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth
const columnCount =
requestedColumns === undefined
? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns)
: clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns)
const columns = buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let occupied = Array.from({ length: columnCount }, () => false)
const pushRow = () => {
rows.push(sortRow(row))
row = []
occupied = Array.from({ length: columnCount }, () => false)
}
for (const item of items) {
const wantedSpan = itemSpan(item, columnCount)
const explicitCol = itemColStart(item, columnCount, wantedSpan)
let col = explicitCol ?? firstFreeCol(occupied, wantedSpan)
if (col === null || (explicitCol !== null && !rangeIsFree(occupied, explicitCol, wantedSpan))) {
if (row.length > 0) {
pushRow()
}
col = explicitCol ?? 0
}
row.push({
col,
id: item.id,
span: wantedSpan,
width: spanWidth(columns, col, wantedSpan, safeGap)
})
occupyRange(occupied, col, wantedSpan)
}
if (row.length > 0) {
rows.push(sortRow(row))
}
return { columnCount, columns, rows }
}
// ── Track solver (grid-template-columns/rows for character cells) ──────────
//
// A track is either a fixed cell count (`number`) or a fractional share
// (`{ fr, min? }`) of whatever space the fixed tracks leave over — the same
// fixed-vs-flex split the desktop pane-shell's track model uses, solved in
// integer terminal cells. `min` defaults to 1 so a track never disappears.
export type GridTrackSize = number | { fr: number; min?: number }
const trackFr = (track: GridTrackSize) => (typeof track === 'number' ? 0 : Math.max(0.0001, track.fr))
const trackMin = (track: GridTrackSize) => (typeof track === 'number' ? 1 : Math.max(1, toInt(track.min ?? 1, 1)))
/** Floor-divide `budget` across `weights`, spreading the remainder left-to-right. */
const distributeByWeight = (budget: number, weights: number[]) => {
const total = weights.reduce((acc, w) => acc + w, 0)
if (total <= 0 || budget <= 0) {
return weights.map(() => 0)
}
const shares = weights.map(w => Math.floor((budget * w) / total))
let remainder = budget - shares.reduce((acc, s) => acc + s, 0)
for (let i = 0; remainder > 0 && i < shares.length; i++, remainder--) {
shares[i]! += 1
}
return shares
}
/**
* Solve track sizes for a `total`-cell axis with `gap` cells between tracks.
* Fixed tracks take their size; `fr` tracks share the leftover by weight,
* re-pinning any track that falls below its `min` and re-solving the rest.
* Every track ends up 1 cell; when the axis genuinely can't fit, the
* overflow is shaved off the trailing tracks so the sum never exceeds the
* drawable width unless every track is already at 1.
*/
export function resolveGridTracks(total: number, gap: number, tracks: GridTrackSize[]): number[] {
const count = tracks.length
if (!count) {
return []
}
const safeGap = Math.max(0, toInt(gap, 0))
const usable = Math.max(count, toInt(total, count) - safeGap * (count - 1))
const sizes = Array.from({ length: count }, () => 0)
let remaining = usable
let unpinned: number[] = []
tracks.forEach((track, idx) => {
if (typeof track === 'number') {
sizes[idx] = Math.max(1, toInt(track, 1))
remaining -= sizes[idx]!
} else {
unpinned.push(idx)
}
})
while (unpinned.length) {
const shares = distributeByWeight(Math.max(0, remaining), unpinned.map(idx => trackFr(tracks[idx]!)))
const violating = unpinned.filter((idx, i) => shares[i]! < trackMin(tracks[idx]!))
if (!violating.length) {
unpinned.forEach((idx, i) => {
sizes[idx] = shares[i]!
})
break
}
for (const idx of violating) {
sizes[idx] = trackMin(tracks[idx]!)
remaining -= sizes[idx]!
}
unpinned = unpinned.filter(idx => !violating.includes(idx))
}
let overflow = sizes.reduce((acc, s) => acc + s, 0) - usable
for (let idx = count - 1; idx >= 0 && overflow > 0; idx--) {
const give = Math.min(overflow, sizes[idx]! - 1)
sizes[idx] -= give
overflow -= give
}
return sizes
}
// ── 2D area layout (the workspace mode) ────────────────────────────────────
//
// `layoutGridAreas` is the full two-axis grid: explicit column AND row tracks,
// items with `col`/`row` pins and `colSpan`/`rowSpan`, dense first-fit
// auto-placement, and each cell solved to an absolute `{ x, y, width, height }`
// rect. It is the terminal-cell equivalent of the desktop zone editor's
// `GridLayout` (rowPercents / columnPercents / cellChildMap with merged
// spans) — the renderer absolutely positions each rect, which is what makes
// `rowSpan` representable at all under Yoga flexbox.
export interface GridAreaItem {
id: string
/** Explicit column start (0-based). Explicitly pinned items may overlap. */
col?: number
colSpan?: number
/** Explicit row start (0-based). Rows grow implicitly as needed. */
row?: number
rowSpan?: number
}
export interface GridAreaCell {
col: number
colSpan: number
height: number
id: string
row: number
rowSpan: number
width: number
x: number
y: number
}
export interface GridAreasLayout {
cells: GridAreaCell[]
columnCount: number
columnSizes: number[]
height: number
rowCount: number
rowSizes: number[]
width: number
}
export interface GridAreasOptions {
/** Column tracks — a count (equal `fr` shares) or explicit track list. */
columns: GridTrackSize[] | number
gap?: number
height: number
items: GridAreaItem[]
rowGap?: number
/** Row tracks. Omitted or short: implicit rows are `{ fr: 1 }`. */
rows?: GridTrackSize[] | number
width: number
}
const normalizeTracks = (tracks: GridTrackSize[] | number | undefined, fallbackCount: number): GridTrackSize[] => {
if (Array.isArray(tracks) && tracks.length) {
return tracks
}
const count = Math.max(1, toInt(typeof tracks === 'number' ? tracks : fallbackCount, 1))
return Array.from({ length: count }, () => ({ fr: 1 }))
}
interface PlacedArea {
col: number
colSpan: number
id: string
row: number
rowSpan: number
}
const ensureOccupancyRows = (occupied: boolean[][], rowCount: number, columnCount: number) => {
while (occupied.length < rowCount) {
occupied.push(Array.from({ length: columnCount }, () => false))
}
}
const areaIsFree = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
if (occupied[r]![c]) {
return false
}
}
}
return true
}
const occupyArea = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
occupied[r]![c] = true
}
}
}
/** Dense first-fit auto-placement (CSS `grid-auto-flow: row dense`). */
const placeGridItems = (items: GridAreaItem[], columnCount: number): PlacedArea[] => {
const occupied: boolean[][] = [Array.from({ length: columnCount }, () => false)]
return items.map(item => {
const colSpan = clamp(toInt(item.colSpan ?? 1, 1), 1, columnCount)
const rowSpan = Math.max(1, toInt(item.rowSpan ?? 1, 1))
const pinnedCol = item.col === undefined ? null : clamp(toInt(item.col, 0), 0, columnCount - colSpan)
const pinnedRow = item.row === undefined ? null : Math.max(0, toInt(item.row, 0))
let row: number
let col: number
if (pinnedRow !== null && pinnedCol !== null) {
row = pinnedRow
col = pinnedCol
} else if (pinnedRow !== null) {
// Pinned row: first free column run, overlapping at col 0 when full.
col = 0
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, pinnedRow, c, rowSpan, colSpan)) {
col = c
break
}
}
row = pinnedRow
} else {
// Auto (or pinned col): scan row-major for the first fitting rect. New
// rows are always empty, so the scan terminates.
row = 0
col = pinnedCol ?? 0
for (let r = 0; ; r++) {
const found =
pinnedCol !== null
? areaIsFree(occupied, r, pinnedCol, rowSpan, colSpan)
? pinnedCol
: null
: (() => {
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, r, c, rowSpan, colSpan)) {
return c
}
}
return null
})()
if (found !== null) {
row = r
col = found
break
}
}
}
occupyArea(occupied, row, col, rowSpan, colSpan)
return { col, colSpan, id: item.id, row, rowSpan }
})
}
const trackOffsets = (sizes: number[], gap: number) => {
const offsets: number[] = []
let cursor = 0
for (const size of sizes) {
offsets.push(cursor)
cursor += size + gap
}
return offsets
}
const spanSize = (sizes: number[], start: number, span: number, gap: number) => {
const end = Math.min(sizes.length, start + span)
return sizes.slice(start, end).reduce((acc, s) => acc + s, 0) + gap * Math.max(0, end - start - 1)
}
export function layoutGridAreas({
columns,
gap = 1,
height,
items,
rowGap = 0,
rows,
width
}: GridAreasOptions): GridAreasLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeRowGap = Math.max(0, toInt(rowGap, 0))
const safeWidth = Math.max(1, toInt(width, 1))
const safeHeight = Math.max(1, toInt(height, 1))
const columnTracks = normalizeTracks(columns, 1)
const columnCount = columnTracks.length
const placed = placeGridItems(items, columnCount)
const placedRowCount = placed.reduce((acc, area) => Math.max(acc, area.row + area.rowSpan), 0)
const explicitRowTracks = rows === undefined ? [] : normalizeTracks(rows, 1)
const rowCount = Math.max(1, explicitRowTracks.length, placedRowCount)
const rowTracks: GridTrackSize[] = Array.from(
{ length: rowCount },
(_, idx) => explicitRowTracks[idx] ?? { fr: 1 }
)
const columnSizes = resolveGridTracks(safeWidth, safeGap, columnTracks)
const rowSizes = resolveGridTracks(safeHeight, safeRowGap, rowTracks)
const columnStarts = trackOffsets(columnSizes, safeGap)
const rowStarts = trackOffsets(rowSizes, safeRowGap)
const cells = placed.map(area => {
const row = Math.min(area.row, rowCount - 1)
return {
col: area.col,
colSpan: area.colSpan,
height: spanSize(rowSizes, row, area.rowSpan, safeRowGap),
id: area.id,
row,
rowSpan: area.rowSpan,
width: spanSize(columnSizes, area.col, area.colSpan, safeGap),
x: columnStarts[area.col] ?? 0,
y: rowStarts[row] ?? 0
}
})
return {
cells,
columnCount,
columnSizes,
height: safeHeight,
rowCount,
rowSizes,
width: safeWidth
}
}