diff --git a/ui-tui/node_modules b/ui-tui/node_modules
new file mode 120000
index 000000000000..001f2a89107d
--- /dev/null
+++ b/ui-tui/node_modules
@@ -0,0 +1 @@
+/Users/brooklyn/www/hermes-agent/ui-tui/node_modules
\ No newline at end of file
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index 7096798ecf93..59a9872ad756 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -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()
diff --git a/ui-tui/src/__tests__/widgetGrid.test.ts b/ui-tui/src/__tests__/widgetGrid.test.ts
new file mode 100644
index 000000000000..3fed524b6e38
--- /dev/null
+++ b/ui-tui/src/__tests__/widgetGrid.test.ts
@@ -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 })
+ })
+})
diff --git a/ui-tui/src/__tests__/widgetGridComponent.test.tsx b/ui-tui/src/__tests__/widgetGridComponent.test.tsx
new file mode 100644
index 000000000000..1ae15101881b
--- /dev/null
+++ b/ui-tui/src/__tests__/widgetGridComponent.test.tsx
@@ -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 {value}
+}
+
+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()
+
+describe('WidgetGrid component composition', () => {
+ it('renders stateful direct children and nested grids inside cells', () => {
+ const output = renderGrid([
+ {
+ children: ,
+ id: 'stateful'
+ },
+ {
+ children: (
+ , id: 'nested-c1' },
+ { render: () => , 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 => {`tall ${cell.width}x${cell.height}`}, rowSpan: 2 },
+ { children: top-right, id: 'b' },
+ { children: cell => {`bottom-right y${cell.y}`}, id: 'c' }
+ ]
+
+ const output = renderToText()
+
+ // 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 => {`header h${cell.height}`}, id: 'header' },
+ { children: cell => {`body h${cell.height}`}, id: 'body' },
+ { children: cell => {`footer h${cell.height}`}, id: 'footer' }
+ ]
+
+ const output = renderToText(
+
+ )
+
+ 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()
+
+ 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')
+ })
+})
diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts
index 33f2faec24b5..f9959eba00ce 100644
--- a/ui-tui/src/app/interfaces.ts
+++ b/ui-tui/src/app/interfaces.ts
@@ -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
diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts
index bff021f31530..6eaaf581dc0c 100644
--- a/ui-tui/src/app/overlayStore.ts
+++ b/ui-tui/src/app/overlayStore.ts
@@ -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,
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index 3a952c399d5e..25dfa54b2a36 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -107,7 +107,9 @@ export const coreCommands: SlashCommand[] = [
'/details [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'
},
diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts
index b4bfc16bfbc9..868414a66d04 100644
--- a/ui-tui/src/app/slash/commands/debug.ts
+++ b/ui-tui/src/app/slash/commands/debug.ts
@@ -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([
+ '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',
diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts
index 2f2cdff3c718..8bd5f2944a01 100644
--- a/ui-tui/src/app/useInputHandlers.ts
+++ b/ui-tui/src/app/useInputHandlers.ts
@@ -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) {
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
index d641e39d0d4a..3810d5a0da1c 100644
--- a/ui-tui/src/components/appLayout.tsx
+++ b/ui-tui/src/components/appLayout.tsx
@@ -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 && }
+
+ {overlay.dialog && (
+
+
+
+ )}
)
})
diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx
index 80e040e4fe9a..5075598db618 100644
--- a/ui-tui/src/components/appOverlays.tsx
+++ b/ui-tui/src/components/appOverlays.tsx
@@ -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 (
+ {overlay.gridTest && (
+
+
+
+ )}
+
{overlay.sessions && (
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([])
+
+ 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 (
+
+ {words.join(' ')}
+ ▌
+
+ )
+}
+
+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 (
+
+
+ {format(current)}
+
+
+ {sparkRows(history, width, rows).map((line, idx) => (
+
+ {line}
+
+ ))}
+
+ )
+}
+
+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 (
+
+ {lines.map(({ line, recent }, idx) => (
+
+ {line}
+
+ ))}
+
+ )
+}
+
+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 (
+
+ {new Date().toLocaleTimeString()}
+ {`up ${Math.floor(uptime / 60)}m ${uptime % 60}s`}
+ {`${tick} ticks`}
+
+ )
+}
+
+// ── 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 }) => ,
+ title: 'token stream'
+ },
+ {
+ id: 'throughput',
+ render: ({ height, t, width }) => (
+ `${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 }) => (
+ `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 }) => (
+ `${Math.round(20 + v * 180)} ms`}
+ height={height}
+ interval={250}
+ sample={walkSampler}
+ t={t}
+ width={width}
+ />
+ ),
+ title: 'latency'
+ },
+ {
+ id: 'tools',
+ render: ({ height, t, width }) => ,
+ title: 'tool feed'
+ },
+ {
+ id: 'meta',
+ render: ({ height, t, 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 (
+
+
+ {focused ? '▸ ' : ' '}
+ {title}
+ {main ? ' ·' : ''}
+
+
+
+ {children({ height: innerHeight, t, width: innerWidth })}
+
+
+ )
+}
+
+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 => (
+
+
+ hermes mission control
+
+ {`main: ${main.title}`}
+
+ )
+ },
+ ...ordered.map((def, idx) => ({
+ colSpan: idx === 0 ? 2 : 1,
+ id: `stream-${def.id}`,
+ render: (cell: GridAreaCell) => (
+
+ {def.render}
+
+ ),
+ rowSpan: idx === 0 ? 2 : 1
+ }))
+ ]
+
+ return (
+
+ )
+})
diff --git a/ui-tui/src/components/gridTestOverlay.tsx b/ui-tui/src/components/gridTestOverlay.tsx
new file mode 100644
index 000000000000..b53ed031f679
--- /dev/null
+++ b/ui-tui/src/components/gridTestOverlay.tsx
@@ -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 => (
+
+ )
+ }
+ })
+
+ return (
+
+
+
+ {state.zoomed ? `/grid-test / r${state.activeRow + 1} c${state.activeCol + 1}` : '/grid-test'}
+
+ {state.streams ? 'streams' : `${state.cols}x${state.rows} grid`}
+
+
+
+ {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'}
+
+
+
+ {state.zoomed ? (
+
+ ) : state.streams ? (
+
+ ) : state.areas ? (
+
+ ) : (
+
+ )}
+
+
+ {!state.zoomed && !state.streams && (
+
+
+ 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'}
+
+
+ )}
+
+ )
+}
+
+/**
+ * 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) => ,
+ rowSpan: idx === 0 ? rowSpanFirst : 1
+ }))
+
+ return (
+
+ )
+}
+
+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 (
+
+
+ {label}
+
+
+ {cell.height >= 5 && (
+
+ {cell.colSpan > 1 || cell.rowSpan > 1 ? `${cell.colSpan}x${cell.rowSpan}` : `${cell.width}w`}
+
+ )}
+
+ )
+}
+
+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 (
+
+ {nested && width >= 10 ? (
+ <>
+
+
+ {label}
+
+
+
+
+ >
+ ) : (
+
+
+ {label}
+
+
+ )}
+
+ )
+}
+
+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 (
+
+
+ parent {parentLabel}
+
+ ),
+ id: 'header-title'
+ },
+ {
+ children: (
+
+ nested child grid
+
+ ),
+ id: 'header-meta'
+ }
+ ]}
+ />
+
+
+
+
+
+ )
+}
+
+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 =>
+ }))
+}
+
+function MiniCell({ color, label, width }: { color: string; label: string; width: number }) {
+ return (
+
+ {label}
+
+ )
+}
diff --git a/ui-tui/src/components/overlay.tsx b/ui-tui/src/components/overlay.tsx
new file mode 100644
index 000000000000..58c7b93250de
--- /dev/null
+++ b/ui-tui/src/components/overlay.tsx
@@ -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 ``
+ * 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 && (
+
+ {Array.from({ length: rows }, (_, i) => (
+
+ {scrimLine}
+
+ ))}
+
+ )}
+
+
+ {children}
+
+ >
+ )
+}
+
+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 (
+
+ {title && (
+
+
+ {title}
+
+
+ )}
+
+ {children}
+
+ {hint && (
+ {typeof hint === 'string' ? {hint} : hint}
+ )}
+
+ )
+}
+
+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]]
+}
diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx
index 941ee0b27529..31c8f355f007 100644
--- a/ui-tui/src/components/skillsHub.tsx
+++ b/ui-tui/src/components/skillsHub.tsx
@@ -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>({})
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 }>('skills.manage', { action: 'list' })
@@ -303,6 +306,7 @@ interface SkillInfo {
interface SkillsHubProps {
gw: GatewayClient
+ maxWidth?: number
onClose: () => void
t: Theme
}
diff --git a/ui-tui/src/components/widgetGrid.tsx b/ui-tui/src/components/widgetGrid.tsx
new file mode 100644
index 000000000000..291df66522fc
--- /dev/null
+++ b/ui-tui/src/components/widgetGrid.tsx
@@ -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 (
+
+ {layout.rows.map((row, rowIdx) => (
+
+
+
+
+
+ {safeRowGap > 0 && rowIdx < layout.rows.length - 1 ? : null}
+
+ ))}
+
+ )
+})
+
+const WidgetRow = memo(function WidgetRow({
+ cells,
+ columns,
+ gap,
+ widgetById
+}: {
+ cells: WidgetGridCell[]
+ columns: number[]
+ gap: number
+ widgetById: Map
+}) {
+ 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 (
+
+ {spacerWidth > 0 ? : null}
+
+
+ )
+ })}
+ >
+ )
+})
+
+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 (
+
+ {node}
+
+ )
+})
+
+// ── 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 (
+
+ {layout.cells.map(cell => (
+
+ ))}
+
+ )
+})
+
+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 (
+
+ {node}
+
+ )
+})
diff --git a/ui-tui/src/lib/widgetGrid.ts b/ui-tui/src/lib/widgetGrid.ts
new file mode 100644
index 000000000000..331861d77061
--- /dev/null
+++ b/ui-tui/src/lib/widgetGrid.ts
@@ -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
+ }
+}