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 && ( + + + {overlay.dialog.body.split('\n').map((line, i) => ( + {line || ' '} + ))} + + + )} ) }) 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 + } +}