mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
feat(ui-tui): widget-app SDK — registry, host, dispatch; demos become apps
The SDK the desktop app already has, ported to the TUI: a WidgetApp contract (id/help/mode/init/reduce/render/usage), a registry, and a host that owns the active widget, routes input to its reducer, and renders it. The grid-test and dialog-test debug surfaces are reimplemented as widget apps instead of bespoke overlay state, and slash commands are generated from the registry. Input for an open widget is owned by the active app (supersedes the demo-only stacked-modal routing) — the single active widget enforces topmost-owns-input structurally.
This commit is contained in:
parent
32a9f2acbc
commit
d8fcab4736
20 changed files with 628 additions and 441 deletions
|
|
@ -74,7 +74,8 @@ describe('createSlashHandler', () => {
|
|||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ describe('createSlashHandler', () => {
|
|||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import {
|
||||
applyVoiceRecordResponse,
|
||||
dismissSensitivePrompt,
|
||||
handleIdleHotkeyExit,
|
||||
handleStackedModalInput,
|
||||
shouldAllowIdleHotkeyExit,
|
||||
shouldFallThroughForScroll
|
||||
} from '../app/useInputHandlers.js'
|
||||
|
|
@ -146,96 +144,3 @@ describe('dismissSensitivePrompt', () => {
|
|||
await pending
|
||||
})
|
||||
})
|
||||
|
||||
// Review on #20379 (finding 3): a dialog stacked over /grid-test was
|
||||
// visually modal but did not receive input — the grid branch ran first, so
|
||||
// every advertised close key (Esc/q/Enter) mutated the hidden grid instead
|
||||
// of closing the visible dialog. Input routing must follow visual stacking.
|
||||
describe('handleStackedModalInput — dialog over grid-test', () => {
|
||||
const baseModalKey = {
|
||||
ctrl: false,
|
||||
downArrow: false,
|
||||
escape: false,
|
||||
leftArrow: false,
|
||||
return: false,
|
||||
rightArrow: false,
|
||||
upArrow: false
|
||||
}
|
||||
|
||||
const grid: GridTestState = {
|
||||
activeCol: 1,
|
||||
activeRow: 1,
|
||||
areas: false,
|
||||
cols: 4,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: 3,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams: false,
|
||||
zoomed: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ gridTest: { ...grid } })
|
||||
})
|
||||
|
||||
const openDialogViaD = () => {
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'd')).toBe(true)
|
||||
expect(getOverlayState().dialog).not.toBeNull()
|
||||
expect(getOverlayState().gridTest).not.toBeNull()
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Esc', { ...baseModalKey, escape: true }, ''],
|
||||
['q', baseModalKey, 'q'],
|
||||
['Enter', { ...baseModalKey, return: true }, ''],
|
||||
['Ctrl+C', { ...baseModalKey, ctrl: true }, 'c']
|
||||
])('%s closes only the dialog, leaving the grid untouched', (_label, key, ch) => {
|
||||
openDialogViaD()
|
||||
|
||||
const gridBefore = getOverlayState().gridTest
|
||||
|
||||
expect(handleStackedModalInput(getOverlayState(), key, ch)).toBe(true)
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
// The grid must be byte-identical: not closed, not zoomed, not reset.
|
||||
expect(getOverlayState().gridTest).toBe(gridBefore)
|
||||
})
|
||||
|
||||
it('after the dialog closes, the same keys route to the grid again', () => {
|
||||
openDialogViaD()
|
||||
handleStackedModalInput(getOverlayState(), { ...baseModalKey, escape: true }, '')
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
|
||||
// Esc now closes the grid — the dialog no longer shields it.
|
||||
expect(handleStackedModalInput(getOverlayState(), { ...baseModalKey, escape: true }, '')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toBeNull()
|
||||
})
|
||||
|
||||
it('the dialog swallows grid keys entirely while open (no leak-through)', () => {
|
||||
openDialogViaD()
|
||||
|
||||
const gridBefore = getOverlayState().gridTest
|
||||
|
||||
// 'a' toggles areas mode when the grid has focus — it must not now.
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'a')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toBe(gridBefore)
|
||||
expect(getOverlayState().dialog).not.toBeNull()
|
||||
})
|
||||
|
||||
it('stacking works from streams mode too', () => {
|
||||
patchOverlayState({ gridTest: { ...grid, streams: true } })
|
||||
openDialogViaD()
|
||||
|
||||
expect(handleStackedModalInput(getOverlayState(), { ...baseModalKey, return: true }, '')).toBe(true)
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
expect(getOverlayState().gridTest?.streams).toBe(true)
|
||||
})
|
||||
|
||||
it('reports unconsumed when neither modal is up', () => {
|
||||
resetOverlayState()
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { renderSync, Text } from '@hermes/ink'
|
|||
import React, { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from '../app/interfaces.js'
|
||||
import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js'
|
||||
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
function StatefulCell({ label }: { label: string }) {
|
||||
|
|
|
|||
69
ui-tui/src/__tests__/widgetSdk.test.ts
Normal file
69
ui-tui/src/__tests__/widgetSdk.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { dialogTestApp, gridTestApp } from '../sdk/apps/index.js'
|
||||
import { closeWidget, dispatchWidgetInput, launchWidget, openWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp, listWidgetApps } from '../sdk/registry.js'
|
||||
import type { WidgetInput } from '../sdk/types.js'
|
||||
|
||||
const key = (overrides: Partial<WidgetInput['key']> = {}, ch = ''): WidgetInput =>
|
||||
({ ch, key: { ctrl: false, escape: false, leftArrow: false, return: false, rightArrow: false, ...overrides } }) as WidgetInput
|
||||
|
||||
beforeEach(() => resetOverlayState())
|
||||
|
||||
describe('widget SDK host', () => {
|
||||
it('registers the reference apps', () => {
|
||||
expect(listWidgetApps()).toEqual(expect.arrayContaining(['dialog-test', 'grid-test']))
|
||||
expect(getWidgetApp('grid-test')).toBe(gridTestApp)
|
||||
})
|
||||
|
||||
it('launch → dispatch → close lifecycle drives the overlay slot', () => {
|
||||
expect(launchWidget('grid-test', '5x2')).toBeNull()
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ cols: 5, rows: 2 })
|
||||
|
||||
// Reducer output lands back in the slot.
|
||||
expect(dispatchWidgetInput(key({}, 'l'))).toBe(true)
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ activeCol: 1 })
|
||||
|
||||
// null from reduce closes.
|
||||
expect(dispatchWidgetInput(key({ escape: true }))).toBe(true)
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
|
||||
// Nothing active → not handled.
|
||||
expect(dispatchWidgetInput(key({}, 'x'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refused launches return the usage line and leave the slot empty', () => {
|
||||
expect(launchWidget('grid-test', 'not-a-size !')).toBe(gridTestApp.usage)
|
||||
expect(launchWidget('nope', '')).toMatch(/unknown widget app/)
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
it('apps stack each other via the typed programmatic launch', () => {
|
||||
expect(launchWidget('grid-test', '')).toBeNull()
|
||||
|
||||
// `d` swaps the active app to the dialog demo.
|
||||
expect(dispatchWidgetInput(key({}, 'd'))).toBe(true)
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'dialog-test' })
|
||||
|
||||
// Enter closes the dialog app.
|
||||
expect(dispatchWidgetInput(key({ return: true }))).toBe(true)
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
it('openWidget is a typed direct launch', () => {
|
||||
openWidget(dialogTestApp, { body: 'hi', zone: 'top-right' })
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'dialog-test', state: { zone: 'top-right' } })
|
||||
closeWidget()
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
it('a widget in the slot blocks the composer', async () => {
|
||||
const { $isBlocked } = await import('../app/overlayStore.js')
|
||||
|
||||
expect($isBlocked.get()).toBe(false)
|
||||
launchWidget('dialog-test', 'center')
|
||||
expect($isBlocked.get()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '../gatewayTypes.js'
|
||||
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import type { RpcResult } from '../lib/rpc.js'
|
||||
import type { ActiveWidget } from '../sdk/types.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type {
|
||||
ApprovalReq,
|
||||
|
|
@ -283,8 +284,7 @@ export interface OverlayState {
|
|||
billing: BillingOverlayState | null
|
||||
clarify: ClarifyReq | null
|
||||
confirm: ConfirmReq | null
|
||||
dialog: DialogState | null
|
||||
gridTest: GridTestState | null
|
||||
widget: ActiveWidget | null
|
||||
journey: boolean
|
||||
modelPicker: boolean | { refresh?: boolean }
|
||||
pager: null | PagerState
|
||||
|
|
@ -297,41 +297,12 @@ export interface OverlayState {
|
|||
sudo: null | SudoReq
|
||||
}
|
||||
|
||||
export interface DialogState {
|
||||
body: string
|
||||
hint?: string
|
||||
title?: string
|
||||
zone?: 'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right'
|
||||
}
|
||||
|
||||
export interface PagerState {
|
||||
lines: string[]
|
||||
offset: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** Number of live panels in the /grid-test streams demo (focus wraps mod this). */
|
||||
export const GRID_STREAM_COUNT = 6
|
||||
|
||||
export interface GridTestState {
|
||||
activeCol: number
|
||||
activeRow: number
|
||||
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
|
||||
areas: boolean
|
||||
cols: number
|
||||
gap: null | number
|
||||
nested: boolean
|
||||
paddingX: null | number
|
||||
rows: number
|
||||
/** Streams mode: live-updating panels tiled by GridAreas. */
|
||||
streams: boolean
|
||||
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
|
||||
streamFocus: number
|
||||
/** Streams mode: which panel owns the promoted 2x2 slot. */
|
||||
streamMain: number
|
||||
zoomed: boolean
|
||||
}
|
||||
|
||||
export interface TranscriptRow {
|
||||
index: number
|
||||
key: string
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ const buildOverlayState = (): OverlayState => ({
|
|||
billing: null,
|
||||
clarify: null,
|
||||
confirm: null,
|
||||
dialog: null,
|
||||
gridTest: null,
|
||||
widget: null,
|
||||
journey: false,
|
||||
modelPicker: false,
|
||||
pager: null,
|
||||
|
|
@ -33,8 +32,6 @@ export const $isBlocked = computed(
|
|||
billing,
|
||||
clarify,
|
||||
confirm,
|
||||
dialog,
|
||||
gridTest,
|
||||
journey,
|
||||
modelPicker,
|
||||
pager,
|
||||
|
|
@ -44,7 +41,8 @@ export const $isBlocked = computed(
|
|||
sessions,
|
||||
skillsHub,
|
||||
subscription,
|
||||
sudo
|
||||
sudo,
|
||||
widget
|
||||
}) =>
|
||||
Boolean(
|
||||
agents ||
|
||||
|
|
@ -52,8 +50,6 @@ export const $isBlocked = computed(
|
|||
billing ||
|
||||
clarify ||
|
||||
confirm ||
|
||||
dialog ||
|
||||
gridTest ||
|
||||
journey ||
|
||||
modelPicker ||
|
||||
pager ||
|
||||
|
|
@ -63,7 +59,8 @@ export const $isBlocked = computed(
|
|||
sessions ||
|
||||
skillsHub ||
|
||||
subscription ||
|
||||
sudo
|
||||
sudo ||
|
||||
widget
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -88,8 +85,7 @@ export const resetFlowOverlays = () =>
|
|||
...buildOverlayState(),
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
dialog: $overlayState.get().dialog,
|
||||
gridTest: $overlayState.get().gridTest,
|
||||
widget: $overlayState.get().widget,
|
||||
journey: $overlayState.get().journey,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
petPicker: $overlayState.get().petPicker,
|
||||
|
|
|
|||
|
|
@ -1,116 +1,31 @@
|
|||
// Importing the apps barrel registers the reference apps before launch.
|
||||
import '../../../sdk/apps/index.js'
|
||||
|
||||
import { terminalBackgroundHex } from '@hermes/ink'
|
||||
|
||||
import { formatBytes, performHeapDump } from '../../../lib/memory.js'
|
||||
import { launchWidget } from '../../../sdk/host.js'
|
||||
import { detectLightMode } from '../../../theme.js'
|
||||
import type { DialogState } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { getUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
|
||||
const GRID_TEST_MAX_SIZE = 12
|
||||
/** Slash command → SDK widget-app launch. The app owns parsing (init),
|
||||
* keybindings (reduce), and placement (render); refusals print usage. */
|
||||
const widgetCommand = (name: string, help: string): SlashCommand => ({
|
||||
help,
|
||||
name,
|
||||
run: (arg, ctx) => {
|
||||
const err = launchWidget(name, arg)
|
||||
|
||||
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
|
||||
if (err) {
|
||||
ctx.transcript.sys(err)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(GRID_TEST_MAX_SIZE, Math.trunc(value)))
|
||||
}
|
||||
|
||||
const parseGridTestSize = (arg: string) => {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { cols: 4, rows: 3 }
|
||||
}
|
||||
|
||||
const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i)
|
||||
|
||||
if (grid) {
|
||||
return { cols: clampGridSize(Number(grid[1]), 4), rows: clampGridSize(Number(grid[2]), 3) }
|
||||
}
|
||||
|
||||
const [cols, rows, ...rest] = trimmed.split(/\s+/)
|
||||
|
||||
if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { cols: clampGridSize(Number(cols), 4), rows: clampGridSize(Number(rows), 3) }
|
||||
}
|
||||
})
|
||||
|
||||
export const debugCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'open an interactive widget-grid demo overlay',
|
||||
name: 'grid-test',
|
||||
run: (arg, ctx) => {
|
||||
const streams = arg.trim().toLowerCase() === 'streams'
|
||||
const size = streams ? { cols: 4, rows: 3 } : parseGridTestSize(arg)
|
||||
|
||||
if (!size) {
|
||||
return ctx.transcript.sys(GRID_TEST_USAGE)
|
||||
}
|
||||
|
||||
patchOverlayState({
|
||||
gridTest: {
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols: size.cols,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: size.rows,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams,
|
||||
zoomed: false
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'open a sample dialog overlay with a faked backdrop',
|
||||
name: 'dialog-test',
|
||||
run: (arg, ctx) => {
|
||||
const trimmed = arg.trim().toLowerCase()
|
||||
const zone = (trimmed || 'center') as DialogState['zone']
|
||||
|
||||
if (!DIALOG_TEST_ZONES.has(zone)) {
|
||||
return ctx.transcript.sys(DIALOG_TEST_USAGE)
|
||||
}
|
||||
|
||||
patchOverlayState({
|
||||
dialog: {
|
||||
body: [
|
||||
'This is a viewport-level overlay with a backdrop.',
|
||||
'',
|
||||
`Zone: ${zone}`,
|
||||
'Try: /dialog-test top-right · bottom · left · ...'
|
||||
].join('\n'),
|
||||
hint: 'Esc/q/Enter close · Ctrl+C close',
|
||||
title: 'Dialog primitive',
|
||||
zone
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'),
|
||||
widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'),
|
||||
|
||||
{
|
||||
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ import type {
|
|||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
|
||||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||
import { closeWidget, dispatchWidgetInput } from '../sdk/host.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import {
|
||||
type GatewayRpc,
|
||||
GRID_STREAM_COUNT,
|
||||
type GridTestState,
|
||||
type InputHandlerActions,
|
||||
type InputHandlerContext,
|
||||
type InputHandlerResult,
|
||||
|
|
@ -130,160 +129,6 @@ export function dismissSensitivePrompt(
|
|||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||
const GRID_TEST_MAX_SIZE = 12
|
||||
|
||||
const cycleAutoNumber = (value: null | number, max: number) => {
|
||||
if (value === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return value >= max ? null : value + 1
|
||||
}
|
||||
|
||||
const keepGridCursorInBounds = (grid: GridTestState): GridTestState => ({
|
||||
...grid,
|
||||
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
|
||||
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
|
||||
})
|
||||
|
||||
interface StackedModalKey {
|
||||
ctrl: boolean
|
||||
downArrow: boolean
|
||||
escape: boolean
|
||||
leftArrow: boolean
|
||||
return: boolean
|
||||
rightArrow: boolean
|
||||
upArrow: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Input routing for the stacked demo modals (dialog over grid-test).
|
||||
* Exported so tests can drive the real dispatch against the overlay store.
|
||||
*
|
||||
* ORDER IS THE CONTRACT: input routing follows visual stacking. /grid-test's
|
||||
* `d` opens a dialog ON TOP of the grid without clearing it — the dialog
|
||||
* branch must run first, or Esc/q/Enter mutate the hidden grid instead of
|
||||
* closing the visible dialog, contradicting its "Esc/q/Enter close" hint
|
||||
* (review on #20379, finding 3).
|
||||
*
|
||||
* Returns true when a modal consumed the key (callers stop routing).
|
||||
*/
|
||||
export function handleStackedModalInput(
|
||||
overlay: Pick<OverlayState, 'dialog' | 'gridTest'>,
|
||||
key: StackedModalKey,
|
||||
ch: string
|
||||
): boolean {
|
||||
if (overlay.dialog) {
|
||||
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q' || key.return) {
|
||||
patchOverlayState({ dialog: null })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!overlay.gridTest) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updateGrid = (fn: (grid: GridTestState) => GridTestState) =>
|
||||
patchOverlayState(prev => (prev.gridTest ? { ...prev, gridTest: keepGridCursorInBounds(fn(prev.gridTest)) } : prev))
|
||||
|
||||
const openDemoDialog = () =>
|
||||
patchOverlayState({
|
||||
dialog: {
|
||||
body: ['Dialog overlaid on top of /grid-test.', '', 'Backdrop dims the grid behind.'].join('\n'),
|
||||
hint: 'Esc/q/Enter close',
|
||||
title: 'Overlay primitive',
|
||||
zone: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
const resetGrid = () =>
|
||||
updateGrid(grid => ({
|
||||
...grid,
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols: 4,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: 3,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams: false,
|
||||
zoomed: false
|
||||
}))
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
patchOverlayState({ gridTest: null })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Streams mode swallows the grid-shape keys: focus cycles across the
|
||||
// live panels and Enter promotes the focused one to the 2x2 slot.
|
||||
if (overlay.gridTest.streams) {
|
||||
if (key.escape || ch === 'q' || ch === 's') {
|
||||
updateGrid(grid => ({ ...grid, streams: false }))
|
||||
} else if (key.return) {
|
||||
updateGrid(grid => ({ ...grid, streamMain: grid.streamFocus }))
|
||||
} else if (ch === 'd') {
|
||||
openDemoDialog()
|
||||
} else if (ch === 'r') {
|
||||
resetGrid()
|
||||
} else if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
|
||||
updateGrid(grid => ({
|
||||
...grid,
|
||||
streamFocus: (grid.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT
|
||||
}))
|
||||
} else if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
|
||||
updateGrid(grid => ({ ...grid, streamFocus: (grid.streamFocus + 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (overlay.gridTest.zoomed && (key.escape || ch === 'q')) {
|
||||
updateGrid(grid => ({ ...grid, zoomed: false }))
|
||||
} else if (key.escape || ch === 'q') {
|
||||
patchOverlayState({ gridTest: null })
|
||||
} else if (key.return) {
|
||||
updateGrid(grid => ({ ...grid, nested: true, zoomed: true }))
|
||||
} else if (ch === 'n') {
|
||||
updateGrid(grid => ({ ...grid, nested: !grid.nested }))
|
||||
} else if (ch === 'a') {
|
||||
updateGrid(grid => ({ ...grid, areas: !grid.areas, streams: false }))
|
||||
} else if (ch === 's') {
|
||||
updateGrid(grid => ({ ...grid, areas: false, streams: true }))
|
||||
} else if (ch === 'g') {
|
||||
updateGrid(grid => ({ ...grid, gap: cycleAutoNumber(grid.gap, 3) }))
|
||||
} else if (ch === 'p') {
|
||||
updateGrid(grid => ({ ...grid, paddingX: cycleAutoNumber(grid.paddingX, 2) }))
|
||||
} else if (ch === 'd') {
|
||||
openDemoDialog()
|
||||
} else if (ch === 'r') {
|
||||
resetGrid()
|
||||
} else if (ch === '+' || ch === '=') {
|
||||
updateGrid(grid => ({ ...grid, cols: clamp(grid.cols + 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === '-' || ch === '_') {
|
||||
updateGrid(grid => ({ ...grid, cols: clamp(grid.cols - 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === ']') {
|
||||
updateGrid(grid => ({ ...grid, rows: clamp(grid.rows + 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === '[') {
|
||||
updateGrid(grid => ({ ...grid, rows: clamp(grid.rows - 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (key.leftArrow || ch === 'h') {
|
||||
updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol - 1, 0, grid.cols - 1) }))
|
||||
} else if (key.rightArrow || ch === 'l') {
|
||||
updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol + 1, 0, grid.cols - 1) }))
|
||||
} else if (key.upArrow || ch === 'k') {
|
||||
updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow - 1, 0, grid.rows - 1) }))
|
||||
} else if (key.downArrow || ch === 'j') {
|
||||
updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow + 1, 0, grid.rows - 1) }))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
||||
|
|
@ -377,12 +222,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return patchOverlayState({ journey: false })
|
||||
}
|
||||
|
||||
if (overlay.gridTest) {
|
||||
return patchOverlayState({ gridTest: null })
|
||||
}
|
||||
|
||||
if (overlay.dialog) {
|
||||
return patchOverlayState({ dialog: null })
|
||||
if (overlay.widget) {
|
||||
return closeWidget()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -567,9 +408,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return
|
||||
}
|
||||
|
||||
// Stacked demo modals (dialog over grid-test): shared routing where
|
||||
// the topmost visual layer consumes input first.
|
||||
if (handleStackedModalInput(overlay, key, ch)) {
|
||||
// Widget apps (SDK): the active app owns every key while open. This
|
||||
// supersedes the demo-only handleStackedModalInput routing from #68999
|
||||
// — grid-test/dialog are now widget apps, so the topmost-modal-owns-
|
||||
// input contract is enforced structurally by the single active widget.
|
||||
if (overlay.widget && dispatchWidgetInput({ ch, key })) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// Importing the apps barrel registers the reference widget apps at startup.
|
||||
import '../sdk/apps/index.js'
|
||||
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Fragment, memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
|
@ -19,6 +22,7 @@ import {
|
|||
} from '../lib/inputMetrics.js'
|
||||
import { PerfPane } from '../lib/perfPane.js'
|
||||
import { composerPromptText } from '../lib/prompt.js'
|
||||
import { ActiveWidgetSlot } from '../sdk/host.js'
|
||||
|
||||
import { AgentsOverlay } from './agentsOverlay.js'
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
|
|
@ -28,7 +32,6 @@ import { FpsOverlay } from './fpsOverlay.js'
|
|||
import { HelpHint } from './helpHint.js'
|
||||
import { Journey } from './journey.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { Dialog, Overlay } from './overlay.js'
|
||||
import { PetKitty, PetSprite } from './petSprite.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||
|
|
@ -568,19 +571,7 @@ export const AppLayout = memo(function AppLayout({
|
|||
{!overlay.agents && <PetPane />}
|
||||
</Box>
|
||||
|
||||
{overlay.dialog && (
|
||||
<Overlay backdrop zone={overlay.dialog.zone ?? 'center'}>
|
||||
<Dialog
|
||||
hint={overlay.dialog.hint ?? 'Esc/q close'}
|
||||
title={overlay.dialog.title}
|
||||
width={Math.min(60, composer.cols - 8)}
|
||||
>
|
||||
{overlay.dialog.body.split('\n').map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
)}
|
||||
<ActiveWidgetSlot />
|
||||
</Shell>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
|
|||
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
|
||||
import { FloatBox } from './appChrome.js'
|
||||
import { BillingOverlay } from './billingOverlay.js'
|
||||
import { GridTestOverlay } from './gridTestOverlay.js'
|
||||
import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { OverlayHint } from './overlayControls.js'
|
||||
|
|
@ -193,7 +192,6 @@ export function FloatingOverlays({
|
|||
const theme = useStore($uiTheme)
|
||||
|
||||
const hasAny =
|
||||
overlay.gridTest ||
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.petPicker ||
|
||||
|
|
@ -220,22 +218,6 @@ export function FloatingOverlays({
|
|||
// column it never binds, so rendering is identical to the pre-grid layout.
|
||||
const widgets: WidgetGridWidget[] = []
|
||||
|
||||
const gridTest = overlay.gridTest
|
||||
|
||||
if (gridTest) {
|
||||
widgets.push({
|
||||
id: 'grid-test',
|
||||
render: () => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
{/* cols-6 = FloatBox chrome (4) + margin (2); no 24-col floor —
|
||||
forcing one would overflow cells narrower than 28 and clip at
|
||||
the terminal edge. */}
|
||||
<GridTestOverlay cols={Math.max(1, cols - 6)} state={gridTest} t={theme} />
|
||||
</FloatBox>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (overlay.sessions) {
|
||||
widgets.push({
|
||||
id: 'sessions',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
|
||||
import type { GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { GridAreas, type GridAreaWidget } from './widgetGrid.js'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
|
||||
import type { GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { GridStreamsDemo } from './gridStreamsDemo.js'
|
||||
|
|
|
|||
68
ui-tui/src/sdk/apps/dialogTest.tsx
Normal file
68
ui-tui/src/sdk/apps/dialogTest.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Text } from '@hermes/ink'
|
||||
|
||||
import { Dialog, Overlay, type OverlayZone } from '../../components/overlay.js'
|
||||
import { defineWidgetApp } from '../registry.js'
|
||||
import { isCtrl } from '../types.js'
|
||||
|
||||
const ZONES: readonly OverlayZone[] = [
|
||||
'bottom',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
'center',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'top-left',
|
||||
'top-right'
|
||||
]
|
||||
|
||||
const USAGE = `usage: /dialog-test [zone] zones: ${ZONES.join(', ')}`
|
||||
|
||||
export interface DialogTestState {
|
||||
body: string
|
||||
hint?: string
|
||||
title?: string
|
||||
zone: OverlayZone
|
||||
}
|
||||
|
||||
const defaultBody = (zone: OverlayZone) =>
|
||||
[
|
||||
'This is a viewport-level overlay with a backdrop.',
|
||||
'',
|
||||
`Zone: ${zone}`,
|
||||
'Try: /dialog-test top-right · bottom · left · ...'
|
||||
].join('\n')
|
||||
|
||||
export const dialogTestApp = defineWidgetApp<DialogTestState>({
|
||||
id: 'dialog-test',
|
||||
usage: USAGE,
|
||||
|
||||
init(arg) {
|
||||
const zone = (arg.trim().toLowerCase() || 'center') as OverlayZone
|
||||
|
||||
if (!ZONES.includes(zone)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { body: defaultBody(zone), hint: 'Esc/q/Enter close · Ctrl+C close', title: 'Dialog primitive', zone }
|
||||
},
|
||||
|
||||
reduce(state, { ch, key }) {
|
||||
return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state
|
||||
},
|
||||
|
||||
render({ cols, state, t }) {
|
||||
void t
|
||||
|
||||
return (
|
||||
<Overlay backdrop zone={state.zone}>
|
||||
<Dialog hint={state.hint ?? 'Esc/q close'} title={state.title} width={Math.min(60, cols - 8)}>
|
||||
{state.body.split('\n').map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
205
ui-tui/src/sdk/apps/gridTest.tsx
Normal file
205
ui-tui/src/sdk/apps/gridTest.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { FloatBox } from '../../components/appChrome.js'
|
||||
import { GridTestOverlay } from '../../components/gridTestOverlay.js'
|
||||
import { Overlay } from '../../components/overlay.js'
|
||||
import { openWidget } from '../host.js'
|
||||
import { defineWidgetApp } from '../registry.js'
|
||||
import { isCtrl, type WidgetInput } from '../types.js'
|
||||
|
||||
import { dialogTestApp } from './dialogTest.js'
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
|
||||
|
||||
const MAX_SIZE = 12
|
||||
const USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||
|
||||
const clampSize = (value: number, fallback: number) => (Number.isFinite(value) ? clamp(Math.round(value), 1, MAX_SIZE) : fallback)
|
||||
|
||||
/** null/number cycle: auto → 0 → 1 → … → max → auto. */
|
||||
const cycleAutoNumber = (value: null | number, max: number) => (value === null ? 0 : value >= max ? null : value + 1)
|
||||
|
||||
const keepCursorInBounds = (grid: GridTestState): GridTestState => ({
|
||||
...grid,
|
||||
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
|
||||
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
|
||||
})
|
||||
|
||||
const initialState = (cols: number, rows: number, streams: boolean): GridTestState => ({
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams,
|
||||
zoomed: false
|
||||
})
|
||||
|
||||
function parseSize(arg: string): null | { cols: number; rows: number } {
|
||||
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: clampSize(Number(grid[1]), 4), rows: clampSize(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: clampSize(Number(cols), 4), rows: clampSize(Number(rows), 3) }
|
||||
}
|
||||
|
||||
const update = (grid: GridTestState, fn: (grid: GridTestState) => GridTestState) => keepCursorInBounds(fn(grid))
|
||||
|
||||
function reduceStreams(grid: GridTestState, { ch, key }: WidgetInput): GridTestState | null {
|
||||
if (key.escape || ch === 'q' || ch === 's') {
|
||||
return update(grid, g => ({ ...g, streams: false }))
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return update(grid, g => ({ ...g, streamMain: g.streamFocus }))
|
||||
}
|
||||
|
||||
if (ch === 'r') {
|
||||
return initialState(4, 3, false)
|
||||
}
|
||||
|
||||
if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
|
||||
return update(grid, g => ({ ...g, streamFocus: (g.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
|
||||
return update(grid, g => ({ ...g, streamFocus: (g.streamFocus + 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
export const gridTestApp = defineWidgetApp<GridTestState>({
|
||||
id: 'grid-test',
|
||||
usage: USAGE,
|
||||
|
||||
init(arg) {
|
||||
const streams = arg.trim().toLowerCase() === 'streams'
|
||||
const size = streams ? { cols: 4, rows: 3 } : parseSize(arg)
|
||||
|
||||
return size ? initialState(size.cols, size.rows, streams) : null
|
||||
},
|
||||
|
||||
reduce(grid, input) {
|
||||
const { ch, key } = input
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `d` opens the dialog app as a nested demo — apps launch each other via
|
||||
// the typed programmatic API; the host swaps the active app.
|
||||
if (ch === 'd') {
|
||||
openWidget(dialogTestApp, {
|
||||
body: 'Dialog overlaid on top of /grid-test.\n\nBackdrop dims the grid behind.',
|
||||
hint: 'Esc/q/Enter close',
|
||||
title: 'Overlay primitive',
|
||||
zone: 'center'
|
||||
})
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
if (grid.streams) {
|
||||
return reduceStreams(grid, input)
|
||||
}
|
||||
|
||||
if (grid.zoomed && (key.escape || ch === 'q')) {
|
||||
return update(grid, g => ({ ...g, zoomed: false }))
|
||||
}
|
||||
|
||||
if (key.escape || ch === 'q') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return update(grid, g => ({ ...g, nested: true, zoomed: true }))
|
||||
}
|
||||
|
||||
if (ch === 'n') {
|
||||
return update(grid, g => ({ ...g, nested: !g.nested }))
|
||||
}
|
||||
|
||||
if (ch === 'a') {
|
||||
return update(grid, g => ({ ...g, areas: !g.areas, streams: false }))
|
||||
}
|
||||
|
||||
if (ch === 's') {
|
||||
return update(grid, g => ({ ...g, areas: false, streams: true }))
|
||||
}
|
||||
|
||||
if (ch === 'g') {
|
||||
return update(grid, g => ({ ...g, gap: cycleAutoNumber(g.gap, 3) }))
|
||||
}
|
||||
|
||||
if (ch === 'p') {
|
||||
return update(grid, g => ({ ...g, paddingX: cycleAutoNumber(g.paddingX, 2) }))
|
||||
}
|
||||
|
||||
if (ch === 'r') {
|
||||
return initialState(4, 3, false)
|
||||
}
|
||||
|
||||
if (ch === '+' || ch === '=') {
|
||||
return update(grid, g => ({ ...g, cols: clamp(g.cols + 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === '-' || ch === '_') {
|
||||
return update(grid, g => ({ ...g, cols: clamp(g.cols - 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === ']') {
|
||||
return update(grid, g => ({ ...g, rows: clamp(g.rows + 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === '[') {
|
||||
return update(grid, g => ({ ...g, rows: clamp(g.rows - 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (key.leftArrow || ch === 'h') {
|
||||
return update(grid, g => ({ ...g, activeCol: g.activeCol - 1 }))
|
||||
}
|
||||
|
||||
if (key.rightArrow || ch === 'l') {
|
||||
return update(grid, g => ({ ...g, activeCol: g.activeCol + 1 }))
|
||||
}
|
||||
|
||||
if (key.upArrow || ch === 'k') {
|
||||
return update(grid, g => ({ ...g, activeRow: g.activeRow - 1 }))
|
||||
}
|
||||
|
||||
if (key.downArrow || ch === 'j') {
|
||||
return update(grid, g => ({ ...g, activeRow: g.activeRow + 1 }))
|
||||
}
|
||||
|
||||
return grid
|
||||
},
|
||||
|
||||
render({ cols, state, t }) {
|
||||
return (
|
||||
<Overlay zone="center">
|
||||
<FloatBox color={t.color.border}>
|
||||
<GridTestOverlay cols={Math.max(24, Math.min(cols - 6, 120))} state={state} t={t} />
|
||||
</FloatBox>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
})
|
||||
24
ui-tui/src/sdk/apps/gridTestState.ts
Normal file
24
ui-tui/src/sdk/apps/gridTestState.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** State for the /grid-test reference app. Lives apart from the app
|
||||
* definition so the render components can import the type without a cycle. */
|
||||
|
||||
/** Number of live panels in the streams demo (focus wraps mod this). */
|
||||
export const GRID_STREAM_COUNT = 6
|
||||
|
||||
export interface GridTestState {
|
||||
activeCol: number
|
||||
activeRow: number
|
||||
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
|
||||
areas: boolean
|
||||
cols: number
|
||||
gap: null | number
|
||||
nested: boolean
|
||||
paddingX: null | number
|
||||
rows: number
|
||||
/** Streams mode: live-updating panels tiled by GridAreas. */
|
||||
streams: boolean
|
||||
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
|
||||
streamFocus: number
|
||||
/** Streams mode: which panel owns the promoted 2x2 slot. */
|
||||
streamMain: number
|
||||
zoomed: boolean
|
||||
}
|
||||
5
ui-tui/src/sdk/apps/index.ts
Normal file
5
ui-tui/src/sdk/apps/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** Reference apps. Importing this module registers them (defineWidgetApp
|
||||
* runs at module load) — appLayout imports it once at startup. */
|
||||
export { dialogTestApp } from './dialogTest.js'
|
||||
export { gridTestApp } from './gridTest.js'
|
||||
export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
|
||||
97
ui-tui/src/sdk/host.tsx
Normal file
97
ui-tui/src/sdk/host.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useStdout } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiTheme } from '../app/uiStore.js'
|
||||
|
||||
import { getWidgetApp } from './registry.js'
|
||||
import type { WidgetApp, WidgetInput } from './types.js'
|
||||
|
||||
/**
|
||||
* The widget-app host. Core integrates through exactly three touchpoints:
|
||||
* launch (slash commands), dispatch (the input pipeline), and the render
|
||||
* slot (appLayout). Everything else — state shape, keybindings, placement —
|
||||
* belongs to the app.
|
||||
*/
|
||||
|
||||
/** Launch by id. Returns null on success, a printable error/usage line on
|
||||
* refusal — the caller owns the transcript. */
|
||||
export function launchWidget(id: string, arg = ''): null | string {
|
||||
const app = getWidgetApp(id)
|
||||
|
||||
if (!app) {
|
||||
return `unknown widget app: ${id}`
|
||||
}
|
||||
|
||||
const state = app.init(arg)
|
||||
|
||||
if (state === null) {
|
||||
return app.usage ?? `usage: /${id}`
|
||||
}
|
||||
|
||||
patchOverlayState({ widget: { appId: id, state } })
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const closeWidget = () => patchOverlayState({ widget: null })
|
||||
|
||||
/** Programmatic, TYPED launch — bypasses string parsing. Apps use this to
|
||||
* stack each other (the host swaps the active app). */
|
||||
export function openWidget<S>(app: WidgetApp<S>, state: S): void {
|
||||
patchOverlayState({ widget: { appId: app.id, state } })
|
||||
}
|
||||
|
||||
/** Feed one keypress to the active app. Returns true when a widget is active
|
||||
* (apps swallow every key while open — the overlay is modal). */
|
||||
export function dispatchWidgetInput(input: WidgetInput): boolean {
|
||||
const active = $overlayState.get().widget
|
||||
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
|
||||
const app = getWidgetApp(active.appId)
|
||||
|
||||
if (!app) {
|
||||
closeWidget()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const next = app.reduce(active.state as never, input)
|
||||
|
||||
if (next === null) {
|
||||
closeWidget()
|
||||
} else if (next !== active.state) {
|
||||
patchOverlayState({ widget: { appId: active.appId, state: next } })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Render slot for the active app — viewport-level, so apps can anchor
|
||||
* `Overlay` zones and backdrops against the full terminal. */
|
||||
export function ActiveWidgetSlot(): ReactNode {
|
||||
const overlay = useStore($overlayState)
|
||||
const t = useStore($uiTheme)
|
||||
const { stdout } = useStdout()
|
||||
|
||||
if (!overlay.widget) {
|
||||
return null
|
||||
}
|
||||
|
||||
const app = getWidgetApp(overlay.widget.appId)
|
||||
|
||||
if (!app) {
|
||||
return null
|
||||
}
|
||||
|
||||
return app.render({
|
||||
cols: stdout?.columns ?? 80,
|
||||
rows: stdout?.rows ?? 24,
|
||||
state: overlay.widget.state as never,
|
||||
t
|
||||
})
|
||||
}
|
||||
48
ui-tui/src/sdk/index.ts
Normal file
48
ui-tui/src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* The TUI widget SDK — the one import surface a widget app needs.
|
||||
*
|
||||
* An app is a `WidgetApp` (state + reducer + render) registered with
|
||||
* `defineWidgetApp` and launched by id (usually from a slash command via
|
||||
* `launchWidget`). While active it owns every keypress and renders in a
|
||||
* viewport-level slot, composing the same layout/theme primitives every
|
||||
* built-in surface uses — so apps inherit grid tracks, zoned overlays,
|
||||
* selection chips, and skin-derived color by construction.
|
||||
*
|
||||
* See `sdk/apps/` for the reference apps (`/grid-test`, `/dialog-test`).
|
||||
*/
|
||||
|
||||
// Layout components + overlay primitives
|
||||
export { Dialog, Overlay, type OverlayZone } from '../components/overlay.js'
|
||||
// Theme + chrome primitives
|
||||
export { OverlayHint, windowItems } from '../components/overlayControls.js'
|
||||
export {
|
||||
ActionRow,
|
||||
chipRowProps,
|
||||
listRowStyle,
|
||||
MenuRow,
|
||||
scrollbarColors,
|
||||
useMenu
|
||||
} from '../components/overlayPrimitives.js'
|
||||
|
||||
export { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
|
||||
|
||||
export { contrastRatio, liftForContrast, mix, relativeLuminance } from '../lib/color.js'
|
||||
// Layout engine
|
||||
export {
|
||||
type GridAreaItem,
|
||||
type GridAreasLayout,
|
||||
type GridAreasOptions,
|
||||
type GridTrackSize,
|
||||
layoutGridAreas,
|
||||
layoutWidgetGrid,
|
||||
resolveGridTracks,
|
||||
type WidgetGridItem,
|
||||
type WidgetGridLayout,
|
||||
type WidgetGridLayoutOptions
|
||||
} from '../lib/widgetGrid.js'
|
||||
|
||||
export type { Theme, ThemeColors } from '../theme.js'
|
||||
// App contract + host
|
||||
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget } from './host.js'
|
||||
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
|
||||
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'
|
||||
15
ui-tui/src/sdk/registry.ts
Normal file
15
ui-tui/src/sdk/registry.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { WidgetApp } from './types.js'
|
||||
|
||||
const apps = new Map<string, WidgetApp<never>>()
|
||||
|
||||
/** Identity helper that pins the state type, then registers. Last writer
|
||||
* wins so a user/plugin app can shadow a built-in of the same id. */
|
||||
export function defineWidgetApp<S>(app: WidgetApp<S>): WidgetApp<S> {
|
||||
apps.set(app.id, app as WidgetApp<never>)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export const getWidgetApp = (id: string): undefined | WidgetApp<never> => apps.get(id)
|
||||
|
||||
export const listWidgetApps = (): string[] => [...apps.keys()].sort()
|
||||
52
ui-tui/src/sdk/types.ts
Normal file
52
ui-tui/src/sdk/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { Key } from '@hermes/ink'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
/** One keypress, as the input pipeline delivers it. */
|
||||
export interface WidgetInput {
|
||||
ch: string
|
||||
key: Key
|
||||
}
|
||||
|
||||
export interface WidgetRenderCtx<S> {
|
||||
/** Terminal columns available to the app. */
|
||||
cols: number
|
||||
/** Terminal rows available to the app. */
|
||||
rows: number
|
||||
state: S
|
||||
t: Theme
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget app: a self-contained overlay surface with its own state, input
|
||||
* reducer, and render — the TUI equivalent of a desktop panel. The host owns
|
||||
* exactly one active app at a time; while active, the app receives every
|
||||
* keypress and the composer is blocked.
|
||||
*
|
||||
* Contract:
|
||||
* - `init(arg)` parses the launch argument (slash-command tail) into initial
|
||||
* state; `null` refuses the launch and the launcher prints `usage`.
|
||||
* - `reduce(state, input)` returns the next state, the SAME reference to
|
||||
* swallow the key unchanged, or `null` to close the app.
|
||||
* - `render(ctx)` returns the overlay node. Compose with the SDK primitives
|
||||
* (`Overlay`, `Dialog`, `WidgetGrid`, `GridAreas`, `chipRowProps`, …) so
|
||||
* placement and theming stay engine-derived.
|
||||
*/
|
||||
export interface WidgetApp<S = unknown> {
|
||||
id: string
|
||||
init(arg: string): null | S
|
||||
reduce(state: S, input: WidgetInput): null | S
|
||||
render(ctx: WidgetRenderCtx<S>): ReactNode
|
||||
usage?: string
|
||||
}
|
||||
|
||||
/** The host's serializable record of the active app. */
|
||||
export interface ActiveWidget {
|
||||
appId: string
|
||||
state: unknown
|
||||
}
|
||||
|
||||
/** Ctrl+<letter> test, shared so app reducers match the core pipeline. */
|
||||
export const isCtrl = (key: { ctrl: boolean }, ch: string, target: string): boolean =>
|
||||
key.ctrl && ch.toLowerCase() === target
|
||||
Loading…
Add table
Add a link
Reference in a new issue