mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
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:
parent
81f4095bc1
commit
58d75ec5c6
17 changed files with 2380 additions and 9 deletions
1
ui-tui/node_modules
Symbolic link
1
ui-tui/node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/Users/brooklyn/www/hermes-agent/ui-tui/node_modules
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
246
ui-tui/src/__tests__/widgetGrid.test.ts
Normal file
246
ui-tui/src/__tests__/widgetGrid.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
143
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal file
143
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
393
ui-tui/src/components/gridStreamsDemo.tsx
Normal file
393
ui-tui/src/components/gridStreamsDemo.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
316
ui-tui/src/components/gridTestOverlay.tsx
Normal file
316
ui-tui/src/components/gridTestOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
ui-tui/src/components/overlay.tsx
Normal file
141
ui-tui/src/components/overlay.tsx
Normal 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]]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
266
ui-tui/src/components/widgetGrid.tsx
Normal file
266
ui-tui/src/components/widgetGrid.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
495
ui-tui/src/lib/widgetGrid.ts
Normal file
495
ui-tui/src/lib/widgetGrid.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue