diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 59a9872ad756..d00646ac9004 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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() }) diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index a9c690cf0230..ef9e676f73e1 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -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) - }) -}) diff --git a/ui-tui/src/__tests__/widgetGridComponent.test.tsx b/ui-tui/src/__tests__/widgetGridComponent.test.tsx index 1ae15101881b..9891b5bbb983 100644 --- a/ui-tui/src/__tests__/widgetGridComponent.test.tsx +++ b/ui-tui/src/__tests__/widgetGridComponent.test.tsx @@ -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 }) { diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts new file mode 100644 index 000000000000..c18773a83683 --- /dev/null +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -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 = {}, 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) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f9959eba00ce..414542046dd8 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 6eaaf581dc0c..e2148ddca5ce 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -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, diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index 047f9798789b..a82ae840bf4a 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -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([ - '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)', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 11f23fad1117..83c712850376 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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, - 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 } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 8befa68e54fa..3efa2c203c15 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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 && } - {overlay.dialog && ( - - - {overlay.dialog.body.split('\n').map((line, i) => ( - {line || ' '} - ))} - - - )} + ) }) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index a6a40117550c..f7fbfae7d2f0 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -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: () => ( - - {/* 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. */} - - - ) - }) - } - if (overlay.sessions) { widgets.push({ id: 'sessions', diff --git a/ui-tui/src/components/gridStreamsDemo.tsx b/ui-tui/src/components/gridStreamsDemo.tsx index ce07acb48a18..eaa1d2ec7d91 100644 --- a/ui-tui/src/components/gridStreamsDemo.tsx +++ b/ui-tui/src/components/gridStreamsDemo.tsx @@ -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' diff --git a/ui-tui/src/components/gridTestOverlay.tsx b/ui-tui/src/components/gridTestOverlay.tsx index 9edab4af848f..12fca743930a 100644 --- a/ui-tui/src/components/gridTestOverlay.tsx +++ b/ui-tui/src/components/gridTestOverlay.tsx @@ -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' diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx new file mode 100644 index 000000000000..61ca348e5c33 --- /dev/null +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -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({ + 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 ( + + + {state.body.split('\n').map((line, i) => ( + {line || ' '} + ))} + + + ) + } +}) + diff --git a/ui-tui/src/sdk/apps/gridTest.tsx b/ui-tui/src/sdk/apps/gridTest.tsx new file mode 100644 index 000000000000..c51ef937bde9 --- /dev/null +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -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({ + 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 ( + + + + + + ) + } +}) diff --git a/ui-tui/src/sdk/apps/gridTestState.ts b/ui-tui/src/sdk/apps/gridTestState.ts new file mode 100644 index 000000000000..ff9bf2884a89 --- /dev/null +++ b/ui-tui/src/sdk/apps/gridTestState.ts @@ -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 +} diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts new file mode 100644 index 000000000000..ee4f6961de1d --- /dev/null +++ b/ui-tui/src/sdk/apps/index.ts @@ -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' diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx new file mode 100644 index 000000000000..dfbaa116b5b2 --- /dev/null +++ b/ui-tui/src/sdk/host.tsx @@ -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(app: WidgetApp, 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 + }) +} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts new file mode 100644 index 000000000000..3eba293c72d5 --- /dev/null +++ b/ui-tui/src/sdk/index.ts @@ -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' diff --git a/ui-tui/src/sdk/registry.ts b/ui-tui/src/sdk/registry.ts new file mode 100644 index 000000000000..f3c56129fa01 --- /dev/null +++ b/ui-tui/src/sdk/registry.ts @@ -0,0 +1,15 @@ +import type { WidgetApp } from './types.js' + +const apps = new Map>() + +/** 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(app: WidgetApp): WidgetApp { + apps.set(app.id, app as WidgetApp) + + return app +} + +export const getWidgetApp = (id: string): undefined | WidgetApp => apps.get(id) + +export const listWidgetApps = (): string[] => [...apps.keys()].sort() diff --git a/ui-tui/src/sdk/types.ts b/ui-tui/src/sdk/types.ts new file mode 100644 index 000000000000..264c805017d6 --- /dev/null +++ b/ui-tui/src/sdk/types.ts @@ -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 { + /** 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 { + id: string + init(arg: string): null | S + reduce(state: S, input: WidgetInput): null | S + render(ctx: WidgetRenderCtx): ReactNode + usage?: string +} + +/** The host's serializable record of the active app. */ +export interface ActiveWidget { + appId: string + state: unknown +} + +/** Ctrl+ 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