mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 20:29:00 +08:00
Compare commits
8 Commits
fix/packag
...
bb/widget-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f93a5c03be | ||
|
|
61b502853f | ||
|
|
5385b7573d | ||
|
|
bc7b84f575 | ||
|
|
f3d958f482 | ||
|
|
dff5dc34ce | ||
|
|
4532182bda | ||
|
|
dbbd5512d5 |
@@ -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()
|
||||
|
||||
|
||||
94
ui-tui/src/__tests__/widgetGrid.test.ts
Normal file
94
ui-tui/src/__tests__/widgetGrid.test.ts
Normal 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 }
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
70
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal file
70
ui-tui/src/__tests__/widgetGridComponent.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
236
ui-tui/src/components/gridTestOverlay.tsx
Normal file
236
ui-tui/src/components/gridTestOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
141
ui-tui/src/components/overlay.tsx
Normal file
141
ui-tui/src/components/overlay.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Box, Text, useStdout } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { $uiTheme } from '../app/uiStore.js'
|
||||
|
||||
export type OverlayZone =
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'center'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'top'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
|
||||
interface OverlayProps {
|
||||
/** Render a faux scrim behind the content (lipgloss-style: spaces + bg color). */
|
||||
backdrop?: boolean
|
||||
/** Background color used to paint the scrim. Defaults to `theme.color.statusBg`. */
|
||||
backdropColor?: string
|
||||
children: ReactNode
|
||||
/** Nine CSS-grid-style zones. Defaults to `center`. */
|
||||
zone?: OverlayZone
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport-level overlay primitive. Positions its child in one of nine zones
|
||||
* and optionally paints a scrim behind it.
|
||||
*
|
||||
* Backdrop uses the canonical TUI pattern (cf. `lipgloss.Place`): each cell is
|
||||
* a SPACE with a backgroundColor, so the area reads as a clean dimmed plane
|
||||
* over the transcript. Ink only paints `backgroundColor` on cells with
|
||||
* content, so the scrim is rendered as explicit lines of spaces — a `<Box>`
|
||||
* with a bg alone would be invisible. Uses stdout dims so placement is
|
||||
* deterministic regardless of tree depth.
|
||||
*/
|
||||
export function Overlay({ backdrop = false, backdropColor, children, zone = 'center' }: OverlayProps) {
|
||||
const { stdout } = useStdout()
|
||||
const theme = useStore($uiTheme)
|
||||
const cols = stdout?.columns ?? 80
|
||||
const rows = stdout?.rows ?? 24
|
||||
const [justify, align] = zoneFlex(zone)
|
||||
const scrimBg = backdropColor ?? theme.color.statusBg
|
||||
const scrimLine = ' '.repeat(cols)
|
||||
|
||||
return (
|
||||
<>
|
||||
{backdrop && (
|
||||
<Box flexDirection="column" height={rows} left={0} position="absolute" top={0} width={cols}>
|
||||
{Array.from({ length: rows }, (_, i) => (
|
||||
<Text backgroundColor={scrimBg} key={i}>
|
||||
{scrimLine}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
alignItems={align}
|
||||
flexDirection="row"
|
||||
height={rows}
|
||||
justifyContent={justify}
|
||||
left={0}
|
||||
position="absolute"
|
||||
top={0}
|
||||
width={cols}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
children: ReactNode
|
||||
hint?: ReactNode
|
||||
title?: string
|
||||
width?: number
|
||||
}
|
||||
|
||||
/** Bordered card with optional title + hint. Pair with `Overlay` for centered modals. */
|
||||
export function Dialog({ children, hint, title, width }: DialogProps) {
|
||||
const theme = useStore($uiTheme)
|
||||
const innerWidth = width !== undefined ? Math.max(1, width - 6) : undefined
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderColor={theme.color.primary}
|
||||
borderStyle="round"
|
||||
flexDirection="column"
|
||||
opaque
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={width}
|
||||
>
|
||||
{title && (
|
||||
<Box justifyContent="center" marginBottom={1} width={innerWidth}>
|
||||
<Text bold color={theme.color.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{hint && (
|
||||
<Box marginTop={1}>{typeof hint === 'string' ? <Text color={theme.color.muted}>{hint}</Text> : hint}</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const zoneFlex = (zone: OverlayZone): ['center' | 'flex-end' | 'flex-start', 'center' | 'flex-end' | 'flex-start'] => {
|
||||
const horizontal = {
|
||||
bottom: 'center',
|
||||
'bottom-left': 'flex-start',
|
||||
'bottom-right': 'flex-end',
|
||||
center: 'center',
|
||||
left: 'flex-start',
|
||||
right: 'flex-end',
|
||||
top: 'center',
|
||||
'top-left': 'flex-start',
|
||||
'top-right': 'flex-end'
|
||||
} as const
|
||||
|
||||
const vertical = {
|
||||
bottom: 'flex-end',
|
||||
'bottom-left': 'flex-end',
|
||||
'bottom-right': 'flex-end',
|
||||
center: 'center',
|
||||
left: 'center',
|
||||
right: 'center',
|
||||
top: 'flex-start',
|
||||
'top-left': 'flex-start',
|
||||
'top-right': 'flex-start'
|
||||
} as const
|
||||
|
||||
return [horizontal[zone], vertical[zone]]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
165
ui-tui/src/components/widgetGrid.tsx
Normal file
165
ui-tui/src/components/widgetGrid.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
166
ui-tui/src/lib/widgetGrid.ts
Normal file
166
ui-tui/src/lib/widgetGrid.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user