Compare commits

...

8 Commits

Author SHA1 Message Date
Brooklyn Nicholson
f93a5c03be fix(ui-tui): use lipgloss-style space-fill backdrop instead of ░ chars
Pattern stolen from rogue / bubbletea: paint the scrim as lines of
SPACES with a backgroundColor (theme.color.statusBg), so the area reads
as a clean dimmed plane over the transcript instead of a noisy shade
texture. \`Dialog\` keeps \`opaque\` so its interior stays distinct from the
scrim. \`backdropChar\` prop dropped — single source of truth.
2026-05-07 11:06:36 -04:00
Brooklyn Nicholson
61b502853f feat(ui-tui): decorated character-fill backdrop for Overlay
Replace the empty `backgroundColor` scrim with an explicit character grid
(`░` by default, `backdropChar` prop to customize). Ink only paints
backgrounds where a Box has content, so an empty centering Box rendered
no scrim — that's why it looked black/white. Now every viewport cell is
painted with the backdrop char in `theme.color.border`, then `opaque` on
the Dialog blocks bleed-through inside the card.
2026-05-07 11:04:24 -04:00
Brooklyn Nicholson
5385b7573d feat(ui-tui): hot-key 'd' in /grid-test overlays a dialog on top
Drop a centered dialog over the active grid so the backdrop visibly dims
the grid behind it — easiest way to see the overlay primitive layer
inside a live overlay.
2026-05-07 11:01:53 -04:00
Brooklyn Nicholson
bc7b84f575 feat(ui-tui): viewport overlay primitive with zones + faked backdrop
Add `Overlay` (zoned absolute positioning over the full viewport, optional
opaque backdrop) and `Dialog` (bordered card with title/hint slots) in
`components/overlay.tsx`. Mounted as a sibling of the main column inside
`AlternateScreen` so it stacks over transcript + composer without
disturbing the layout below.

Nine CSS-grid-style zones (corners + edges + center) drive placement
through deterministic Yoga `justifyContent` / `alignItems`, sourced from
`stdout` dims so positioning is depth-independent.

Wire a `dialog: DialogState | null` slot into the overlay store, gate
input on it, dismiss with Esc/q/Enter/Ctrl+C. Add `/dialog-test [zone]`
to drive every zone, and surface it in `/help` next to `/grid-test`.
2026-05-07 10:59:42 -04:00
Brooklyn Nicholson
f3d958f482 feat(ui-tui): make widget grid composable + drop into TUI surfaces
Make `WidgetGrid` a real composition primitive: cells accept either a
width-aware `render(width, cell)` factory or a direct `children` subtree
(static, stateful, or another `WidgetGrid`). Layout core gains explicit
column counts and per-item `colStart` / `colSpan`, so sparse rows render
without collapsing holes. Cells clip with `overflow: hidden` so child
overflow can never bleed across cell or panel borders.

Wire the grid into the surfaces that were doing hand-rolled padding:
intro hero/session panel, generic `Panel` sections (incl. setup-required
and `/help`), the `/` slash-completion popover, the resume picker, and
the model/provider walkthrough. `/resume` is now full-overlay-span and
its rows are 1-col grid cells instead of fixed 30/30/title chunks.

Add `/grid-test` (debug-only): an interactive overlay that lets you
sweep cols/rows/gap/padding, toggle a sparse nested-preview pattern,
and Enter-zoom a parent cell into a fullscreen child grid. It dogfoods
the polymorphic API: parent labels are centered with a flex-grow box on
inner width (deterministic Yoga math, no `%` ambiguity) and the zoom
header itself is a 2-col `WidgetGrid`.

Tests: layout invariants (exact columns, sparse `colStart`, span
clamping) plus a component-level test that renders stateful direct
children and a nested grid inside cells.
2026-05-06 19:10:47 -05:00
Brooklyn Nicholson
dff5dc34ce Merge origin/main into bb/widget-grid-slots
Resolve the appOverlays.tsx conflict by keeping the widget-grid overlay layout while adopting main's focused ui selectors for theme/session subscriptions.
2026-05-05 15:44:41 -05:00
Brooklyn Nicholson
4532182bda refactor(ui-tui): defer overlay selector perf split
Revert focused overlay selector subscriptions from the widget-grid branch so the layout PR stays scoped to grid behavior and picker sizing only.
2026-05-05 15:29:30 -05:00
Brooklyn Nicholson
dbbd5512d5 feat(ui-tui): add responsive overlay widget grid
Introduce a shared widget grid and width-capped overlay pickers so wide terminals can tile widgets cleanly while reducing overlay rerenders via focused ui store selectors.
2026-05-05 15:09:18 -05:00
18 changed files with 1606 additions and 280 deletions

View File

@@ -18,6 +18,14 @@ describe('createSlashHandler', () => {
expect(getOverlayState().picker).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 })
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('handles /redraw locally without slash worker fallback', () => {
const ctx = buildCtx()

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { layoutWidgetGrid } 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 }
]
])
})
})

View File

@@ -0,0 +1,70 @@
import { PassThrough } from 'stream'
import { renderSync, Text } from '@hermes/ink'
import React, { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js'
import { stripAnsi } from '../lib/text.js'
function StatefulCell({ label }: { label: string }) {
const [value] = useState(label)
return <Text>{value}</Text>
}
const renderGrid = (widgets: WidgetGridWidget[]) => {
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(<WidgetGrid cols={80} columns={2} gap={1} paddingX={0} widgets={widgets} />, {
patchConsole: false,
stderr: stderr as NodeJS.WriteStream,
stdin: stdin as NodeJS.ReadStream,
stdout: stdout as NodeJS.WriteStream
})
instance.unmount()
instance.cleanup()
return stripAnsi(output)
}
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')
})
})

View File

@@ -75,6 +75,8 @@ export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
dialog: DialogState | null
gridTest: GridTestState | null
modelPicker: boolean
pager: null | PagerState
picker: boolean
@@ -83,12 +85,30 @@ 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
}
export interface GridTestState {
activeCol: number
activeRow: number
cols: number
gap: null | number
nested: boolean
paddingX: null | number
rows: number
zoomed: boolean
}
export interface TranscriptRow {
index: number
key: string

View File

@@ -8,6 +8,8 @@ const buildOverlayState = (): OverlayState => ({
approval: null,
clarify: null,
confirm: null,
dialog: null,
gridTest: null,
modelPicker: false,
pager: null,
picker: false,
@@ -20,8 +22,21 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
({ agents, approval, clarify, confirm, dialog, gridTest, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(
agents ||
approval ||
clarify ||
confirm ||
dialog ||
gridTest ||
modelPicker ||
pager ||
picker ||
secret ||
skillsHub ||
sudo
)
)
export const getOverlayState = () => $overlayState.get()
@@ -45,6 +60,8 @@ export const resetFlowOverlays = () =>
...buildOverlayState(),
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
dialog: $overlayState.get().dialog,
gridTest: $overlayState.get().gridTest,
modelPicker: $overlayState.get().modelPicker,
picker: $overlayState.get().picker,
skillsHub: $overlayState.get().skillsHub

View File

@@ -3,7 +3,7 @@ import { forceRedraw } from '@hermes/ink'
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
@@ -74,7 +74,9 @@ export const coreCommands: SlashCommand[] = [
'/details <section> [hidden|collapsed|expanded|reset]',
'override one section (thinking/tools/subagents/activity)'
],
['/fortune [random|daily]', 'show a random or daily local fortune']
['/fortune [random|daily]', 'show a random or daily local fortune'],
['/grid-test [cols]x[rows]', 'open the interactive widget-grid demo'],
['/dialog-test [zone]', 'open a sample dialog overlay with a faked backdrop']
],
title: 'TUI'
},

View File

@@ -1,7 +1,108 @@
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] or /grid-test [cols] [rows]'
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 size = parseGridTestSize(arg)
if (!size) {
return ctx.transcript.sys(GRID_TEST_USAGE)
}
patchOverlayState({
gridTest: {
activeCol: 0,
activeRow: 0,
cols: size.cols,
gap: null,
nested: false,
paddingX: null,
rows: size.rows,
zoomed: false
}
})
}
},
{
help: 'open a sample dialog overlay with a faked backdrop',
name: 'dialog-test',
run: (arg, ctx) => {
const trimmed = arg.trim().toLowerCase()
const zone = (trimmed || 'center') as DialogState['zone']
if (!DIALOG_TEST_ZONES.has(zone)) {
return ctx.transcript.sys(DIALOG_TEST_USAGE)
}
patchOverlayState({
dialog: {
body: [
'This is a viewport-level overlay with a backdrop.',
'',
`Zone: ${zone}`,
'Try: /dialog-test top-right · bottom · left · ...'
].join('\n'),
hint: 'Esc/q/Enter close · Ctrl+C close',
title: 'Dialog primitive',
zone
}
})
}
},
{
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
name: 'heapdump',

View File

@@ -14,15 +14,31 @@ import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platfo
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import type { GridTestState, InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const PRECISION_WHEEL_MIN_GAP_MS = 80
const PRECISION_WHEEL_STICKY_MS = 80
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
@@ -105,6 +121,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
if (overlay.agents) {
return patchOverlayState({ agents: false })
}
if (overlay.gridTest) {
return patchOverlayState({ gridTest: null })
}
if (overlay.dialog) {
return patchOverlayState({ dialog: null })
}
}
const cycleQueue = (dir: 1 | -1) => {
@@ -271,6 +295,108 @@ 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
)
if (isCtrl(key, ch, 'c')) {
return patchOverlayState({ gridTest: null })
}
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 === '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 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'
}
})
}
if (ch === 'r') {
return updateGrid(grid => ({
...grid,
activeCol: 0,
activeRow: 0,
cols: 4,
gap: null,
nested: false,
paddingX: null,
rows: 3,
zoomed: false
}))
}
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')) {
cancelOverlayFromCtrlC()
} else if (key.escape && overlay.picker) {

View File

@@ -24,6 +24,7 @@ import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { MessageLine } from './messageLine.js'
import { Dialog, Overlay } from './overlay.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput, type TextInputMouseApi } from './textInput.js'
@@ -412,6 +413,20 @@ export const AppLayout = memo(function AppLayout({
</>
)}
</Box>
{overlay.dialog && (
<Overlay backdrop zone={overlay.dialog.zone ?? 'center'}>
<Dialog
hint={overlay.dialog.hint ?? 'Esc/q close'}
title={overlay.dialog.title}
width={Math.min(60, composer.cols - 8)}
>
{overlay.dialog.body.split('\n').map((line, i) => (
<Text key={i}>{line || ' '}</Text>
))}
</Dialog>
</Overlay>
)}
</Shell>
)
})

View File

@@ -7,12 +7,14 @@ import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { GridTestOverlay } from './gridTestOverlay.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const COMPLETION_WINDOW = 16
@@ -104,12 +106,23 @@ export function FloatingOverlays({
const sid = useStore($uiSessionId)
const theme = useStore($uiTheme)
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length
const hasAny =
overlay.gridTest ||
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.skillsHub ||
completions.length
if (!hasAny) {
return null
}
const gridCols = Math.max(24, cols - 2)
const gridMaxColumns = cols >= 120 ? 2 : 1
const fullSpan = gridMaxColumns
const capWidth = (cellWidth: number) => Math.max(24, cellWidth - 4)
// Fixed viewport centered on compIdx — previously the slice end was
// compIdx + 8 so the dropdown grew from 8 rows to 16 as the user scrolled
// down, bouncing the height on every keystroke.
@@ -117,87 +130,156 @@ export function FloatingOverlays({
const start = Math.max(0, Math.min(compIdx - Math.floor(COMPLETION_WINDOW / 2), completions.length - viewportSize))
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
const widgets: WidgetGridWidget[] = []
const gridTest = overlay.gridTest
if (gridTest) {
widgets.push({
id: 'grid-test',
render: width => (
<FloatBox color={theme.color.border}>
<GridTestOverlay cols={capWidth(width)} state={gridTest} t={theme} />
</FloatBox>
),
span: fullSpan
})
}
if (overlay.picker) {
widgets.push({
id: 'picker',
render: width => (
<FloatBox color={theme.color.border}>
<SessionPicker
gw={gw}
maxWidth={capWidth(width)}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={theme}
/>
</FloatBox>
)}
),
span: fullSpan
})
}
{overlay.modelPicker && (
if (overlay.modelPicker) {
widgets.push({
id: 'model-picker',
render: width => (
<FloatBox color={theme.color.border}>
<ModelPicker
gw={gw}
maxWidth={capWidth(width)}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={sid}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.skillsHub && (
if (overlay.skillsHub) {
widgets.push({
id: 'skills-hub',
render: width => (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
<SkillsHub
gw={gw}
maxWidth={capWidth(width)}
onClose={() => patchOverlayState({ skillsHub: false })}
t={theme}
/>
</FloatBox>
)}
)
})
}
{overlay.pager && (
if (overlay.pager) {
const pager = overlay.pager
widgets.push({
id: 'pager',
render: width => (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box flexDirection="column" paddingX={1} paddingY={1} width={capWidth(width)}>
{pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={theme.color.primary}>
{overlay.pager.title}
{pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<OverlayHint t={theme}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${overlay.pager.lines.length} lines)`}
{pager.offset + pagerPageSize < pager.lines.length
? `↑↓/jk line · Enter/Space/PgDn page · b/PgUp back · g/G top/bottom · Esc/q close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})`
: `end · ↑↓/jk · b/PgUp back · g top · Esc/q close (${pager.lines.length} lines)`}
</OverlayHint>
</Box>
</Box>
</FloatBox>
)}
),
span: fullSpan
})
}
{!!completions.length && (
if (completions.length) {
const completionWidgets: WidgetGridWidget[] = completions.slice(start, start + viewportSize).map((item, i) => {
const idx = start + i
return {
id: `${idx}:${item.text}:${item.display}:${item.meta ?? ''}`,
render: () => {
const active = idx === compIdx
return (
<Box
backgroundColor={active ? theme.color.completionCurrentBg : undefined}
flexDirection="row"
width="100%"
>
<Text bold color={theme.color.label}>
{item.display}
</Text>
{item.meta ? <Text color={theme.color.muted}> {item.meta}</Text> : null}
</Box>
)
}
}
})
widgets.push({
id: 'completions',
render: width => (
<FloatBox color={theme.color.primary}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, start + viewportSize).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? theme.color.completionCurrentBg : undefined}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold color={theme.color.label}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={theme.color.muted}> {item.meta}</Text> : null}
</Box>
)
})}
</Box>
<WidgetGrid
cols={capWidth(width)}
columns={1}
depth={1}
gap={0}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={completionWidgets}
/>
</FloatBox>
)}
),
span: fullSpan
})
}
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
<WidgetGrid cols={gridCols} maxColumns={gridMaxColumns} minColumnWidth={46} rowGap={0} widgets={widgets} />
</Box>
)
}

View File

@@ -7,6 +7,8 @@ import { flat } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { PanelSection, SessionInfo } from '../types.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const LOADER_TICK_MS = 120
function InlineLoader({ label, t }: { label: string; t: Theme }) {
@@ -63,11 +65,10 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
const heroLines = caduceus(t.color, t.bannerHero || undefined)
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
const wide = cols >= 90 && leftW + 40 < cols
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
const lineBudget = Math.max(12, w - 2)
const panelCols = Math.max(20, cols - 8)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
const truncLine = (pfx: string, items: string[]) => {
const truncLine = (lineBudget: number, pfx: string, items: string[]) => {
let line = ''
let shown = 0
@@ -85,7 +86,13 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
return line
}
const section = (title: string, data: Record<string, string[]>, max = 8, overflowLabel = 'more…') => {
const section = (
lineBudget: number,
title: string,
data: Record<string, string[]>,
max = 8,
overflowLabel = 'more…'
) => {
const entries = Object.entries(data).sort()
const shown = entries.slice(0, max)
const overflow = entries.length - max
@@ -103,7 +110,7 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
shown.map(([k, vs]) => (
<Text key={k} wrap="truncate">
<Text color={t.color.muted}>{strip(k)}: </Text>
<Text color={t.color.text}>{truncLine(strip(k) + ': ', vs)}</Text>
<Text color={t.color.text}>{truncLine(lineBudget, strip(k) + ': ', vs)}</Text>
</Text>
))
)}
@@ -117,98 +124,157 @@ export function SessionPanel({ info, sid, t }: SessionPanelProps) {
)
}
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
{wide && (
<Box flexDirection="column" marginRight={2} width={leftW}>
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)}
<Box flexDirection="column" width={w}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
{section('Tools', info.tools, 8, 'more toolsets…')}
{section('Skills', info.skills)}
{info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.accent}>
MCP Servers
</Text>
{info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
</Box>
)}
const heroWidget: WidgetGridWidget = {
colSpan: 2,
id: 'hero',
render: width => (
<Box flexDirection="column" width={width}>
<ArtLines lines={heroLines} />
<Text />
<Text color={t.color.text}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>
<Text color={t.color.accent}>
{info.model.split('/').pop()}
<Text color={t.color.muted}> · Nous Research</Text>
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
<Text bold={false} color={t.color.warn} dimColor>
{' '}
- run{' '}
</Text>
<Text bold color={t.color.warn}>
{info.update_command || 'hermes update'}
</Text>
<Text bold={false} color={t.color.warn} dimColor>
{' '}
to update
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{info.cwd || process.cwd()}
</Text>
{sid && (
<Text>
<Text color={t.color.sessionLabel}>Session: </Text>
<Text color={t.color.sessionBorder}>{sid}</Text>
</Text>
)}
</Box>
)
}
const contentWidget: WidgetGridWidget = {
colSpan: wide ? 3 : 1,
id: 'content',
render: width => {
const lineBudget = Math.max(12, width - 2)
return (
<Box flexDirection="column" width={width}>
<Box justifyContent="center" marginBottom={1}>
<Text bold color={t.color.primary}>
{t.brand.name}
{info.version ? ` v${info.version}` : ''}
{info.release_date ? ` (${info.release_date})` : ''}
</Text>
</Box>
{section(lineBudget, 'Tools', info.tools, 8, 'more toolsets…')}
{section(lineBudget, 'Skills', info.skills)}
{info.mcp_servers && info.mcp_servers.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={t.color.accent}>
MCP Servers
</Text>
{info.mcp_servers.map(s => (
<Text key={s.name} wrap="truncate">
<Text color={t.color.muted}>{` ${s.name} `}</Text>
<Text color={t.color.muted}>{`[${s.transport}]`}</Text>
<Text color={t.color.muted}>: </Text>
{s.connected ? (
<Text color={t.color.text}>
{s.tools} tool{s.tools === 1 ? '' : 's'}
</Text>
) : (
<Text color={t.color.error}>failed</Text>
)}
</Text>
))}
</Box>
)}
<Text />
<Text color={t.color.text}>
{flat(info.tools).length} tools{' · '}
{flat(info.skills).length} skills
{info.mcp_servers?.length ? ` · ${info.mcp_servers.length} MCP` : ''}
{' · '}
<Text color={t.color.muted}>/help for commands</Text>
</Text>
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
<Text bold color={t.color.warn}>
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
<Text bold={false} color={t.color.warn} dimColor>
{' '}
- run{' '}
</Text>
<Text bold color={t.color.warn}>
{info.update_command || 'hermes update'}
</Text>
<Text bold={false} color={t.color.warn} dimColor>
{' '}
to update
</Text>
</Text>
)}
</Box>
)
}
}
const widgets = wide ? [heroWidget, contentWidget] : [contentWidget]
return (
<Box borderColor={t.color.border} borderStyle="round" marginBottom={1} paddingX={2} paddingY={1}>
<WidgetGrid
cols={panelCols}
columns={wide ? 5 : 1}
gap={wide ? 2 : 0}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={widgets}
/>
</Box>
)
}
export function Panel({ sections, t, title }: PanelProps) {
const cols = useStdout().stdout?.columns ?? 80
const panelCols = Math.max(24, cols - 8)
const columnCount = sections.length > 1 && panelCols >= 96 ? 2 : 1
const widgets: WidgetGridWidget[] = sections.map((sec, si) => ({
id: `${si}:${sec.title ?? sec.text ?? 'section'}`,
render: width => (
<Box flexDirection="column" width={width}>
{sec.title && (
<Text bold color={t.color.accent}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.muted}>{k}</Text>
<Text color={t.color.muted}> </Text>
<Text color={t.color.text}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.text} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box>
)
}))
return (
<Box borderColor={t.color.border} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
<Box justifyContent="center" marginBottom={1}>
@@ -217,30 +283,15 @@ export function Panel({ sections, t, title }: PanelProps) {
</Text>
</Box>
{sections.map((sec, si) => (
<Box flexDirection="column" key={si} marginTop={si > 0 ? 1 : 0}>
{sec.title && (
<Text bold color={t.color.accent}>
{sec.title}
</Text>
)}
{sec.rows?.map(([k, v], ri) => (
<Text key={ri} wrap="truncate">
<Text color={t.color.muted}>{k.padEnd(20)}</Text>
<Text color={t.color.text}>{v}</Text>
</Text>
))}
{sec.items?.map((item, ii) => (
<Text color={t.color.text} key={ii} wrap="truncate">
{item}
</Text>
))}
{sec.text && <Text color={t.color.muted}>{sec.text}</Text>}
</Box>
))}
<WidgetGrid
cols={panelCols}
columns={columnCount}
gap={2}
minColumnWidth={1}
paddingX={0}
rowGap={1}
widgets={widgets}
/>
</Box>
)
}

View File

@@ -0,0 +1,236 @@
import { Box, Text } from '@hermes/ink'
import type { GridTestState } from '../app/interfaces.js'
import type { Theme } from '../theme.js'
import { 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.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'
: 'arrows/hjkl move · Enter zoom · d dialog · +/- cols · [] rows · g gap · p pad · n nest · q close'}
</Text>
<Box marginTop={1}>
{state.zoomed ? (
<ZoomedGridCell cols={gridCols} parentLabel={activeLabel} 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 && (
<Box marginTop={1}>
<Text color={t.color.muted} wrap="truncate">
gap {state.gap ?? 'auto'} · pad {state.paddingX ?? 'auto'} · nested {state.nested ? 'on' : 'off'}
</Text>
</Box>
)}
</Box>
)
}
function GridCell({
active,
label,
nested,
nestedMode,
t,
width
}: {
active: boolean
label: string
nested: boolean
nestedMode: boolean
t: Theme
width: number
}) {
const padX = width >= 14 ? 1 : 0
const inner = Math.max(1, width - 2 - padX * 2)
const borderColor = active ? t.color.primary : t.color.border
const height = nestedMode ? NESTED_CELL_HEIGHT : FLAT_CELL_HEIGHT
const labelColor = active ? t.color.primary : t.color.label
return (
<Box
borderColor={borderColor}
borderStyle="round"
flexDirection="column"
height={height}
paddingX={padX}
width={width}
>
{nested && width >= 10 ? (
<>
<Box justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
<WidgetGrid
cols={inner}
columns={2}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, 3, 2)}
/>
</>
) : (
<Box alignItems="center" flexGrow={1} justifyContent="center" width={inner}>
<Text bold={active} color={labelColor}>
{label}
</Text>
</Box>
)}
</Box>
)
}
function ZoomedGridCell({ cols, parentLabel, t }: { cols: number; parentLabel: string; t: Theme }) {
const childColumns = cols >= 72 ? 4 : 2
const contentWidth = Math.max(1, cols - 4)
return (
<Box
borderColor={t.color.primary}
borderStyle="round"
flexDirection="column"
paddingX={1}
paddingY={1}
width={cols}
>
<WidgetGrid
cols={contentWidth}
columns={2}
depth={1}
gap={1}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={[
{
children: (
<Text bold color={t.color.primary}>
parent {parentLabel}
</Text>
),
id: 'header-title'
},
{
children: (
<Box justifyContent="flex-end" width="100%">
<Text color={t.color.muted}>nested child grid</Text>
</Box>
),
id: 'header-meta'
}
]}
/>
<Box height={1} />
<WidgetGrid
cols={contentWidth}
columns={childColumns}
depth={1}
gap={NESTED_GAP}
minColumnWidth={1}
paddingX={0}
rowGap={0}
widgets={childCellWidgets(t, childColumns * 2, childColumns)}
/>
</Box>
)
}
const childCellWidgets = (t: Theme, count: number, columns: number): WidgetGridWidget[] => {
const colors = [t.color.ok, t.color.warn, t.color.accent, t.color.muted, t.color.primary]
// 3-cell preview: two side-by-side, third spans the full row.
const lastSpansRow = count === 3
return Array.from({ length: count }, (_, idx) => ({
colSpan: lastSpansRow && idx === count - 1 ? columns : 1,
id: `child-c${idx + 1}`,
render: w => <MiniCell color={colors[idx % colors.length]!} label={`c${idx + 1}`} width={w} />
}))
}
function MiniCell({ color, label, width }: { color: string; label: string; width: number }) {
return (
<Box
alignItems="center"
borderColor={color}
borderStyle="single"
height={MINI_CELL_HEIGHT}
justifyContent="center"
overflow="hidden"
width={width}
>
<Text color={color}>{label}</Text>
</Box>
)
}

View File

@@ -9,6 +9,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowItems } from './overlayControls.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const VISIBLE = 12
const MIN_WIDTH = 40
@@ -16,7 +17,7 @@ const MAX_WIDTH = 90
type Stage = 'provider' | 'key' | 'model' | 'disconnect'
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
export function ModelPicker({ gw, maxWidth, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [currentModel, setCurrentModel] = useState('')
const [err, setErr] = useState('')
@@ -30,11 +31,13 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
const [keyError, setKeyError] = useState('')
const { stdout } = useStdout()
// Pin the picker to a stable width so the FloatBox parent (which shrinks-
// to-fit with alignSelf="flex-start") doesn't resize as long provider /
// model names scroll into view, and so `wrap="truncate-end"` on each row
// has an actual constraint to truncate against.
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
// Pin the picker to a stable width so the FloatBox parent doesn't resize as
// long provider/model names scroll into view. Optional maxWidth lets
// callers constrain it inside multi-column widget layouts.
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<ModelOptionsResponse>('model.options', sessionId ? { session_id: sessionId } : {})
@@ -105,7 +108,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
gw.request<{ provider?: ModelOptionProvider }>('model.save_key', {
slug: provider?.slug,
api_key: keyInput.trim(),
...(sessionId ? { session_id: sessionId } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ provider?: ModelOptionProvider }>(raw)
@@ -118,9 +121,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
}
// Update the provider in our list with fresh data
setProviders(prev =>
prev.map(p => p.slug === r.provider!.slug ? r.provider! : p)
)
setProviders(prev => prev.map(p => (p.slug === r.provider!.slug ? r.provider! : p)))
setKeyInput('')
setKeySaving(false)
setStage('model')
@@ -166,7 +167,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
setKeySaving(true)
gw.request<{ disconnected?: boolean }>('model.disconnect', {
slug: provider.slug,
...(sessionId ? { session_id: sessionId } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
.then(raw => {
const r = asRpcResult<{ disconnected?: boolean }>(raw)
@@ -174,9 +175,16 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
if (r?.disconnected) {
// Mark provider as unauthenticated in local state
setProviders(prev =>
prev.map(p => p.slug === provider.slug
? { ...p, authenticated: false, models: [], total_models: 0, warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure' }
: p
prev.map(p =>
p.slug === provider.slug
? {
...p,
authenticated: false,
models: [],
total_models: 0,
warning: p.key_env ? `paste ${p.key_env} to activate` : 'run `hermes model` to configure'
}
: p
)
)
}
@@ -302,17 +310,23 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
Paste your API key below (saved to ~/.hermes/.env)
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{provider.key_env}:
</Text>
<Text color={t.color.accent} wrap="truncate-end">
{' '}{masked || '(empty)'}{keySaving ? '' : '▎'}
{' '}
{masked || '(empty)'}
{keySaving ? '' : '▎'}
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keyError ? (
<Text color={t.color.label} wrap="truncate-end">
@@ -323,7 +337,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
saving
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)}
<OverlayHint t={t}>Enter save · Ctrl+U clear · Esc back</OverlayHint>
@@ -339,7 +355,9 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
Disconnect {provider.name}?
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
<Text color={t.color.muted} wrap="truncate-end">
This removes saved credentials for {provider.name}.
@@ -349,10 +367,14 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
You can re-authenticate later by selecting it again.
</Text>
<Text color={t.color.muted} wrap="truncate-end"> </Text>
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
{keySaving ? (
<Text color={t.color.muted} wrap="truncate-end">disconnecting</Text>
<Text color={t.color.muted} wrap="truncate-end">
disconnecting
</Text>
) : (
<OverlayHint t={t}>y/Enter confirm · n/Esc cancel</OverlayHint>
)}
@@ -362,20 +384,45 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
// ── Provider selection stage ─────────────────────────────────────────
if (stage === 'provider') {
const rows = providers.map(
(p, i) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
const suffix = p.authenticated === false
? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)')
: `${modelCount} models`
const rows = providers.map((p, i) => {
const authMark = p.authenticated === false ? '○' : p.is_current ? '*' : '●'
const modelCount = p.total_models ?? p.models?.length ?? 0
return `${authMark} ${names[i]} · ${suffix}`
}
)
const suffix =
p.authenticated === false ? (p.auth_type === 'api_key' ? '(no key)' : '(needs setup)') : `${modelCount} models`
return `${authMark} ${names[i]} · ${suffix}`
})
const { items, offset } = windowItems(rows, providerIdx, VISIBLE)
const rowWidgets: WidgetGridWidget[] = Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
const p = providers[idx]
const dimmed = p?.authenticated === false
return {
id: p?.slug ?? `pad-${i}`,
render: () =>
row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.accent : dimmed ? t.color.label : t.color.muted}
inverse={providerIdx === idx}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{idx + 1}. {row}
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)
}
})
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
@@ -396,29 +443,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{offset > 0 ? `${offset} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
const p = providers[idx]
const dimmed = p?.authenticated === false
return row ? (
<Text
bold={providerIdx === idx}
color={providerIdx === idx ? t.color.accent : dimmed ? t.color.label : t.color.muted}
inverse={providerIdx === idx}
key={providers[idx]?.slug ?? `row-${idx}`}
wrap="truncate-end"
>
{providerIdx === idx ? '▸ ' : ' '}
{idx + 1}. {row}
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
})}
<WidgetGrid cols={width} columns={1} depth={1} gap={0} minColumnWidth={1} rowGap={0} widgets={rowWidgets} />
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < rows.length ? `${rows.length - offset - VISIBLE} more` : ' '}
@@ -435,6 +460,46 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
// ── Model selection stage ────────────────────────────────────────────
const { items, offset } = windowItems(models, modelIdx, VISIBLE)
const rowWidgets: WidgetGridWidget[] = Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
if (!row) {
return {
id: !models.length && i === 0 ? 'empty' : `pad-${i}`,
render: () =>
!models.length && i === 0 ? (
<Text color={t.color.muted} wrap="truncate-end">
no models listed for this provider
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
{' '}
</Text>
)
}
}
return {
id: `${provider?.slug ?? 'prov'}:${idx}:${row}`,
render: () => {
const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' '
return (
<Text
bold={modelIdx === idx}
color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx}
wrap="truncate-end"
>
{prefix}
{idx + 1}. {row}
</Text>
)
}
}
})
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent} wrap="truncate-end">
@@ -451,37 +516,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
{offset > 0 ? `${offset} more` : ' '}
</Text>
{Array.from({ length: VISIBLE }, (_, i) => {
const row = items[i]
const idx = offset + i
if (!row) {
return !models.length && i === 0 ? (
<Text color={t.color.muted} key="empty" wrap="truncate-end">
no models listed for this provider
</Text>
) : (
<Text color={t.color.muted} key={`pad-${i}`} wrap="truncate-end">
{' '}
</Text>
)
}
const prefix = modelIdx === idx ? '▸ ' : row === currentModel ? '* ' : ' '
return (
<Text
bold={modelIdx === idx}
color={modelIdx === idx ? t.color.accent : t.color.muted}
inverse={modelIdx === idx}
key={`${provider?.slug ?? 'prov'}:${idx}:${row}`}
wrap="truncate-end"
>
{prefix}
{idx + 1}. {row}
</Text>
)
})}
<WidgetGrid cols={width} columns={1} depth={1} gap={0} minColumnWidth={1} rowGap={0} widgets={rowWidgets} />
<Text color={t.color.muted} wrap="truncate-end">
{offset + VISIBLE < models.length ? `${models.length - offset - VISIBLE} more` : ' '}
@@ -499,6 +534,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke
interface ModelPickerProps {
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onSelect: (value: string) => void
sessionId: string | null

View File

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

View File

@@ -7,6 +7,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, useOverlayKeys, windowOffset } from './overlayControls.js'
import { WidgetGrid, type WidgetGridWidget } from './widgetGrid.js'
const VISIBLE = 15
const MIN_WIDTH = 60
@@ -26,7 +27,7 @@ const age = (ts: number) => {
return `${Math.floor(d)}d ago`
}
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
export function SessionPicker({ gw, maxWidth, onCancel, onSelect, t }: SessionPickerProps) {
const [items, setItems] = useState<SessionListItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
@@ -37,7 +38,10 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
const [deleting, setDeleting] = useState(false)
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))
useOverlayKeys({ onClose: onCancel })
@@ -164,6 +168,26 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
}
const offset = windowOffset(items.length, sel, VISIBLE)
const visible = items.slice(offset, offset + VISIBLE)
const rowWidgets: WidgetGridWidget[] = visible.map((s, vi) => {
const i = offset + vi
const selected = sel === i
const pendingDelete = confirmDelete === i
const color = pendingDelete ? t.color.label : selected ? t.color.accent : t.color.muted
const meta = `${s.message_count} msgs, ${age(s.started_at)}, ${s.source || 'tui'}`
const title = pendingDelete ? 'press d again to delete' : s.title || s.preview || '(untitled)'
return {
id: s.id,
render: () => (
<Text bold={selected} color={color} inverse={selected} wrap="truncate-end">
{selected ? '▸ ' : ' '}
{String(i + 1).padStart(2)}. [{s.id}] ({meta}) {title}
</Text>
)
}
})
return (
<Box flexDirection="column" width={width}>
@@ -171,44 +195,11 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
Resume Session
</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.slice(offset, offset + VISIBLE).map((s, vi) => {
const i = offset + vi
const selected = sel === i
const pendingDelete = confirmDelete === i
<WidgetGrid cols={width} columns={1} depth={1} gap={0} minColumnWidth={1} rowGap={0} widgets={rowWidgets} />
return (
<Box key={s.id}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
{selected ? '▸ ' : ' '}
</Text>
<Box width={30}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
{String(i + 1).padStart(2)}. [{s.id}]
</Text>
</Box>
<Box width={30}>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected}>
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
</Text>
</Box>
<Text
bold={selected}
color={pendingDelete ? t.color.label : selected ? t.color.accent : t.color.muted}
inverse={selected}
wrap="truncate-end"
>
{pendingDelete ? 'press d again to delete' : s.title || s.preview || '(untitled)'}
</Text>
</Box>
)
})}
{offset + VISIBLE < items.length && <Text color={t.color.muted}> {items.length - offset - VISIBLE} more</Text>}
{offset + VISIBLE < items.length && <Text color={t.color.muted}> {items.length - offset - VISIBLE} more</Text>}
{err && <Text color={t.color.label}>error: {err}</Text>}
{deleting ? (
<OverlayHint t={t}>deleting</OverlayHint>
@@ -221,6 +212,7 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
interface SessionPickerProps {
gw: GatewayClient
maxWidth?: number
onCancel: () => void
onSelect: (id: string) => void
t: Theme

View File

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

View File

@@ -0,0 +1,165 @@
import { Box } from '@hermes/ink'
import { Fragment, memo, type ReactNode, useMemo } from 'react'
import { 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>
)
})

View File

@@ -0,0 +1,166 @@
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) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const slots = Math.max(1, toInt(columnCount, 1))
const usable = Math.max(1, safeWidth - safeGap * Math.max(0, slots - 1))
const base = Math.floor(usable / slots)
const remainder = usable % slots
return Array.from({ length: slots }, (_, idx) => base + (idx < remainder ? 1 : 0))
}
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 }
}