From d8fcab47360812027eee49cf82d659cd663b0c8c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 19:57:17 -0500 Subject: [PATCH 1/8] =?UTF-8?q?feat(ui-tui):=20widget-app=20SDK=20?= =?UTF-8?q?=E2=80=94=20registry,=20host,=20dispatch;=20demos=20become=20ap?= =?UTF-8?q?ps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/__tests__/createSlashHandler.test.ts | 5 +- ui-tui/src/__tests__/useInputHandlers.test.ts | 97 +-------- .../__tests__/widgetGridComponent.test.tsx | 2 +- ui-tui/src/__tests__/widgetSdk.test.ts | 69 ++++++ ui-tui/src/app/interfaces.ts | 33 +-- ui-tui/src/app/overlayStore.ts | 16 +- ui-tui/src/app/slash/commands/debug.ts | 119 ++-------- ui-tui/src/app/useInputHandlers.ts | 173 +-------------- ui-tui/src/components/appLayout.tsx | 19 +- ui-tui/src/components/appOverlays.tsx | 18 -- ui-tui/src/components/gridStreamsDemo.tsx | 2 +- ui-tui/src/components/gridTestOverlay.tsx | 2 +- ui-tui/src/sdk/apps/dialogTest.tsx | 68 ++++++ ui-tui/src/sdk/apps/gridTest.tsx | 205 ++++++++++++++++++ ui-tui/src/sdk/apps/gridTestState.ts | 24 ++ ui-tui/src/sdk/apps/index.ts | 5 + ui-tui/src/sdk/host.tsx | 97 +++++++++ ui-tui/src/sdk/index.ts | 48 ++++ ui-tui/src/sdk/registry.ts | 15 ++ ui-tui/src/sdk/types.ts | 52 +++++ 20 files changed, 628 insertions(+), 441 deletions(-) create mode 100644 ui-tui/src/__tests__/widgetSdk.test.ts create mode 100644 ui-tui/src/sdk/apps/dialogTest.tsx create mode 100644 ui-tui/src/sdk/apps/gridTest.tsx create mode 100644 ui-tui/src/sdk/apps/gridTestState.ts create mode 100644 ui-tui/src/sdk/apps/index.ts create mode 100644 ui-tui/src/sdk/host.tsx create mode 100644 ui-tui/src/sdk/index.ts create mode 100644 ui-tui/src/sdk/registry.ts create mode 100644 ui-tui/src/sdk/types.ts 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 From fc3be32a9a9774a871d71b317375f41002853f91 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 20:43:28 -0500 Subject: [PATCH 2/8] =?UTF-8?q?feat(ui-tui):=20weather=20reference=20app?= =?UTF-8?q?=20=E2=80=94=20the=20async-data=20contract,=20themed=20ASCII=20?= =?UTF-8?q?art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /weather [location]: wttr.in current conditions behind a Dialog, art bucket table-driven off WWO weather codes, every tint a theme family tone (sun = primary, rain = shell blue, thunder = warn). Proves the async story the demos don't: init returns a loading phase and fires the fetch; results land through the new host.updateWidget, which patches state ONLY while the app is still active — a late resolution can never resurrect a closed app or clobber a different one. `r` refetches; Esc/q/Enter close. Four async-contract tests (loading→ready via updateWidget, late-resolution guard, error phase, keymap). 1253 TS tests green. --- ui-tui/src/__tests__/weatherApp.test.ts | 91 ++++++++++++ ui-tui/src/app/slash/commands/debug.ts | 1 + ui-tui/src/sdk/apps/dialogTest.tsx | 4 +- ui-tui/src/sdk/apps/index.ts | 1 + ui-tui/src/sdk/apps/weather.tsx | 185 ++++++++++++++++++++++++ ui-tui/src/sdk/host.tsx | 14 ++ ui-tui/src/sdk/index.ts | 2 +- 7 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 ui-tui/src/__tests__/weatherApp.test.ts create mode 100644 ui-tui/src/sdk/apps/weather.tsx diff --git a/ui-tui/src/__tests__/weatherApp.test.ts b/ui-tui/src/__tests__/weatherApp.test.ts new file mode 100644 index 000000000000..91f6fae17358 --- /dev/null +++ b/ui-tui/src/__tests__/weatherApp.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { weatherApp, type WeatherState } from '../sdk/apps/index.js' +import { launchWidget } from '../sdk/host.js' +import type { WidgetInput } from '../sdk/types.js' + +const key = (overrides: Partial = {}, ch = ''): WidgetInput => + ({ ch, key: { ctrl: false, escape: false, return: false, ...overrides } }) as WidgetInput + +const wttrReply = (weatherCode: string) => ({ + current_condition: [ + { + FeelsLikeC: '20', + humidity: '40', + temp_C: '22', + weatherCode, + weatherDesc: [{ value: 'Sunny' }], + windspeedKmph: '7' + } + ], + nearest_area: [{ areaName: [{ value: 'Austin' }], country: [{ value: 'USA' }] }] +}) + +const activeState = () => getOverlayState().widget?.state as undefined | WeatherState + +beforeEach(() => resetOverlayState()) +afterEach(() => vi.unstubAllGlobals()) + +describe('weather reference app (async contract)', () => { + it('launches into loading, lands the fetch via updateWidget', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true })) + ) + + expect(launchWidget('weather', 'Austin')).toBeNull() + expect(activeState()?.phase.kind).toBe('loading') + + await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('ready')) + + const phase = activeState()!.phase + + expect(phase).toMatchObject({ kind: 'ready', report: { area: 'Austin, USA', tempC: '22', weatherCode: 113 } }) + }) + + it('a late resolution cannot resurrect a closed app', async () => { + let resolve!: (value: unknown) => void + + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(r => (resolve = r))) + ) + + launchWidget('weather', '') + expect(activeState()?.phase.kind).toBe('loading') + + // Close while in flight, then resolve. + expect(weatherApp.reduce(activeState()!, key({ escape: true }))).toBeNull() + resetOverlayState() + resolve({ json: async () => wttrReply('113'), ok: true }) + await new Promise(r => setTimeout(r, 0)) + + expect(getOverlayState().widget).toBeNull() + }) + + it('fetch failure lands as an error phase', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ json: async () => ({}), ok: false, status: 503 })) + ) + + launchWidget('weather', 'nowhere') + await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('error')) + expect(activeState()?.phase).toMatchObject({ message: expect.stringContaining('503') }) + }) + + it('r refreshes; Esc/q/Enter close', () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true })) + ) + + const state: WeatherState = { location: 'x', phase: { kind: 'error', message: 'boom' } } + + expect(weatherApp.reduce(state, key({}, 'r'))).toMatchObject({ phase: { kind: 'loading' } }) + expect(weatherApp.reduce(state, key({ escape: true }))).toBeNull() + expect(weatherApp.reduce(state, key({}, 'q'))).toBeNull() + expect(weatherApp.reduce(state, key({ return: true }))).toBeNull() + }) +}) diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index a82ae840bf4a..ea98b55e546c 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -26,6 +26,7 @@ const widgetCommand = (name: string, help: string): SlashCommand => ({ export const debugCommands: SlashCommand[] = [ widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'), widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'), + widgetCommand('weather', 'current conditions with themed ASCII art (wttr.in)'), { help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)', diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 61ca348e5c33..896a62c2dce5 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -51,9 +51,7 @@ export const dialogTestApp = defineWidgetApp({ return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state }, - render({ cols, state, t }) { - void t - + render({ cols, state }) { return ( diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index ee4f6961de1d..3a39a104ac78 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -3,3 +3,4 @@ export { dialogTestApp } from './dialogTest.js' export { gridTestApp } from './gridTest.js' export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js' +export { weatherApp, type WeatherState } from './weather.js' diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx new file mode 100644 index 000000000000..8f01a259dce5 --- /dev/null +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -0,0 +1,185 @@ +import { Box, Text } from '@hermes/ink' + +import { Dialog, Overlay } from '../../components/overlay.js' +import type { Theme } from '../../theme.js' +import { updateWidget } from '../host.js' +import { defineWidgetApp } from '../registry.js' +import { isCtrl } from '../types.js' + +/** + * Weather — the data-backed reference app. Demonstrates the async contract: + * `init` returns a loading state and fires the fetch; the resolution lands + * through `updateWidget`, which no-ops if the app was closed meanwhile. + * Everything visual derives from the theme (art tinted by family tones). + */ + +const USAGE = 'usage: /weather [location] (blank = geolocate by IP)' + +type Phase = { kind: 'error'; message: string } | { kind: 'loading' } | { kind: 'ready'; report: Report } + +export interface WeatherState { + location: string + phase: Phase +} + +interface Report { + area: string + condition: string + feelsC: string + humidity: string + tempC: string + weatherCode: number + windKmph: string +} + +// WWO weather codes → art bucket. Table-driven; unknown codes read as cloud. +type Art = 'cloud' | 'fog' | 'rain' | 'snow' | 'sun' | 'thunder' + +const ART_BY_CODE: readonly [codes: readonly number[], art: Art][] = [ + [[113], 'sun'], + [[116, 119, 122], 'cloud'], + [[143, 248, 260], 'fog'], + [[176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359], 'rain'], + [[179, 182, 185, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377], 'snow'], + [[200, 386, 389, 392, 395], 'thunder'] +] + +const artFor = (code: number): Art => ART_BY_CODE.find(([codes]) => codes.includes(code))?.[1] ?? 'cloud' + +const ART: Record = { + sun: [' \\ / ', ' .-. ', ' ― ( ) ― ', " `-' ", ' / \\ '], + cloud: [' ', ' .--. ', ' .-( ). ', ' (___.__)__) ', ' '], + fog: [' ', ' _ - _ - _ - ', ' _ - _ - _ ', ' _ - _ - _ - ', ' '], + rain: [' .-. ', ' ( ). ', ' (___(__) ', ' ‚ʻ‚ʻ‚ʻ‚ʻ ', ' ‚ʻ‚ʻ‚ʻ‚ʻ '], + snow: [' .-. ', ' ( ). ', ' (___(__) ', ' * * * * ', ' * * * * '], + thunder: [' .-. ', ' ( ). ', ' (___(__) ', ' ⚡‚ʻ⚡‚ʻ ', ' ‚ʻ⚡‚ʻ⚡ '] +} + +/** Art tint rides the theme family: sun in primary gold, rain in the shell + * blue, fog in muted — never hardcoded hexes. */ +const artColor = (art: Art, t: Theme): string => + ({ + cloud: t.color.muted, + fog: t.color.muted, + rain: t.color.shellDollar, + snow: t.color.text, + sun: t.color.primary, + thunder: t.color.warn + })[art] + +async function fetchReport(location: string): Promise { + const res = await fetch(`https://wttr.in/${encodeURIComponent(location)}?format=j1`, { + headers: { 'User-Agent': 'hermes-tui-weather' }, + signal: AbortSignal.timeout(10_000) + }) + + if (!res.ok) { + throw new Error(`wttr.in answered ${res.status}`) + } + + const data = (await res.json()) as { + current_condition?: { + FeelsLikeC?: string + humidity?: string + temp_C?: string + weatherCode?: string + weatherDesc?: { value?: string }[] + windspeedKmph?: string + }[] + nearest_area?: { areaName?: { value?: string }[]; country?: { value?: string }[] }[] + } + + const now = data.current_condition?.[0] + const area = data.nearest_area?.[0] + + if (!now) { + throw new Error('no current conditions in reply') + } + + return { + area: [area?.areaName?.[0]?.value, area?.country?.[0]?.value].filter(Boolean).join(', ') || location || 'here', + condition: now.weatherDesc?.[0]?.value ?? 'unknown', + feelsC: now.FeelsLikeC ?? '?', + humidity: now.humidity ?? '?', + tempC: now.temp_C ?? '?', + weatherCode: Number(now.weatherCode ?? 116), + windKmph: now.windspeedKmph ?? '?' + } +} + +function load(location: string): void { + fetchReport(location).then( + report => updateWidget(weatherApp, state => ({ ...state, phase: { kind: 'ready', report } as Phase })), + (error: unknown) => + updateWidget(weatherApp, state => ({ + ...state, + phase: { kind: 'error', message: error instanceof Error ? error.message : String(error) } as Phase + })) + ) +} + +export const weatherApp = defineWidgetApp({ + id: 'weather', + usage: USAGE, + + init(arg) { + const location = arg.trim() + + load(location) + + return { location, phase: { kind: 'loading' } } + }, + + reduce(state, { ch, key }) { + if (key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c')) { + return null + } + + if (ch === 'r') { + load(state.location) + + return { ...state, phase: { kind: 'loading' } } + } + + return state + }, + + render({ cols, state, t }) { + const { phase } = state + const title = phase.kind === 'ready' ? phase.report.area : 'Weather' + + return ( + + + {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'error' && {phase.message}} + {phase.kind === 'ready' && } + + + ) + } +}) + +function ReadyBody({ report, t }: { report: Report; t: Theme }) { + const art = artFor(report.weatherCode) + + return ( + + + {ART[art].map((line, i) => ( + + {line} + + ))} + + + {report.condition} + + {report.tempC}°C (feels {report.feelsC}°C) + + wind {report.windKmph} km/h + humidity {report.humidity}% + + + ) +} diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index dfbaa116b5b2..c851e58e18c7 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -43,6 +43,20 @@ export function openWidget(app: WidgetApp, state: S): void { patchOverlayState({ widget: { appId: app.id, state } }) } +/** Async state delivery: patch the app's state ONLY while it is still the + * active widget — a late fetch resolution can never resurrect a closed app + * or clobber a different one. This is how data-backed apps land results + * outside the input pipeline (see the weather reference app). */ +export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { + const active = $overlayState.get().widget + + if (active?.appId !== app.id) { + return + } + + patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } }) +} + /** 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 { diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 3eba293c72d5..1c2336cfc3c3 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -43,6 +43,6 @@ export { export type { Theme, ThemeColors } from '../theme.js' // App contract + host -export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget } from './host.js' +export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js' export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js' export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js' From 76b58d6127ab9fc2ee6d77904d77adea5e403cd7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 21:23:06 -0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(ui-tui):=20ambient=20widget=20mode=20?= =?UTF-8?q?=E2=80=94=20registry-driven=20slash=20catalog=20+=20in-flow=20d?= =?UTF-8?q?ock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widgets can render as ambient (glanceable, non-blocking) instead of modal, docked in the normal layout flow above/below the status bar rather than taking over the screen. The slash catalog is generated from the widget registry so new apps surface automatically, and /ticker lands as the first live-animation ambient demo. --- ui-tui/src/__tests__/weatherApp.test.ts | 10 +-- ui-tui/src/__tests__/widgetSdk.test.ts | 16 +++- ui-tui/src/app/interfaces.ts | 3 + ui-tui/src/app/overlayStore.ts | 2 + ui-tui/src/app/slash/commands/debug.ts | 20 ++--- ui-tui/src/components/appLayout.tsx | 3 +- ui-tui/src/hooks/useCompletion.ts | 23 ++++- ui-tui/src/sdk/apps/dialogTest.tsx | 1 + ui-tui/src/sdk/apps/gridTest.tsx | 1 + ui-tui/src/sdk/apps/index.ts | 1 + ui-tui/src/sdk/apps/ticker.tsx | 99 +++++++++++++++++++++ ui-tui/src/sdk/apps/weather.tsx | 18 ++-- ui-tui/src/sdk/host.tsx | 109 +++++++++++++++++++----- ui-tui/src/sdk/registry.ts | 4 +- ui-tui/src/sdk/types.ts | 8 ++ 15 files changed, 267 insertions(+), 51 deletions(-) create mode 100644 ui-tui/src/sdk/apps/ticker.tsx diff --git a/ui-tui/src/__tests__/weatherApp.test.ts b/ui-tui/src/__tests__/weatherApp.test.ts index 91f6fae17358..35a9818850ca 100644 --- a/ui-tui/src/__tests__/weatherApp.test.ts +++ b/ui-tui/src/__tests__/weatherApp.test.ts @@ -22,7 +22,7 @@ const wttrReply = (weatherCode: string) => ({ nearest_area: [{ areaName: [{ value: 'Austin' }], country: [{ value: 'USA' }] }] }) -const activeState = () => getOverlayState().widget?.state as undefined | WeatherState +const activeState = () => getOverlayState().ambient.find(a => a.appId === 'weather')?.state as undefined | WeatherState beforeEach(() => resetOverlayState()) afterEach(() => vi.unstubAllGlobals()) @@ -55,13 +55,13 @@ describe('weather reference app (async contract)', () => { launchWidget('weather', '') expect(activeState()?.phase.kind).toBe('loading') - // Close while in flight, then resolve. - expect(weatherApp.reduce(activeState()!, key({ escape: true }))).toBeNull() - resetOverlayState() + // Toggle closed while in flight (ambient dismissal), then resolve. + expect(launchWidget('weather', '')).toBeNull() + expect(getOverlayState().ambient).toEqual([]) resolve({ json: async () => wttrReply('113'), ok: true }) await new Promise(r => setTimeout(r, 0)) - expect(getOverlayState().widget).toBeNull() + expect(getOverlayState().ambient).toEqual([]) }) it('fetch failure lands as an error phase', async () => { diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index c18773a83683..c00d8e5c05a8 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -13,7 +13,7 @@ beforeEach(() => resetOverlayState()) describe('widget SDK host', () => { it('registers the reference apps', () => { - expect(listWidgetApps()).toEqual(expect.arrayContaining(['dialog-test', 'grid-test'])) + expect(listWidgetApps().map(app => app.id)).toEqual(expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather'])) expect(getWidgetApp('grid-test')).toBe(gridTestApp) }) @@ -59,11 +59,23 @@ describe('widget SDK host', () => { expect(getOverlayState().widget).toBeNull() }) - it('a widget in the slot blocks the composer', async () => { + it('a MODAL widget blocks the composer; ambient never does', async () => { const { $isBlocked } = await import('../app/overlayStore.js') + expect($isBlocked.get()).toBe(false) + launchWidget('ticker', '') expect($isBlocked.get()).toBe(false) launchWidget('dialog-test', 'center') expect($isBlocked.get()).toBe(true) }) + + it('ambient apps dock together and toggle independently', () => { + expect(launchWidget('ticker', 'eurusd')).toBeNull() + expect(launchWidget('weather', '')).toBeNull() + expect(getOverlayState().ambient.map(a => a.appId)).toEqual(['ticker', 'weather']) + + // Relaunch with no arg toggles just that app out of the dock. + expect(launchWidget('ticker', '')).toBeNull() + expect(getOverlayState().ambient.map(a => a.appId)).toEqual(['weather']) + }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 414542046dd8..44ff7fa2a2c1 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -284,6 +284,9 @@ export interface OverlayState { billing: BillingOverlayState | null clarify: ClarifyReq | null confirm: ConfirmReq | null + /** Ambient widget apps — glanceable dock, non-blocking (never in $isBlocked). */ + ambient: ActiveWidget[] + /** Modal widget app — owns input, blocks the composer. */ widget: ActiveWidget | null journey: boolean modelPicker: boolean | { refresh?: boolean } diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index e2148ddca5ce..4e26099a0670 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -9,6 +9,7 @@ const buildOverlayState = (): OverlayState => ({ billing: null, clarify: null, confirm: null, + ambient: [], widget: null, journey: false, modelPicker: false, @@ -85,6 +86,7 @@ export const resetFlowOverlays = () => ...buildOverlayState(), agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, + ambient: $overlayState.get().ambient, widget: $overlayState.get().widget, journey: $overlayState.get().journey, modelPicker: $overlayState.get().modelPicker, diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index ea98b55e546c..7fa0b3797c71 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -5,28 +5,28 @@ import { terminalBackgroundHex } from '@hermes/ink' import { formatBytes, performHeapDump } from '../../../lib/memory.js' import { launchWidget } from '../../../sdk/host.js' +import { listWidgetApps } from '../../../sdk/registry.js' import { detectLightMode } from '../../../theme.js' import { getUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -/** 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, +/** The registry IS the catalog: every registered widget app becomes a slash + * command carrying the app's own help/usage — nothing hardcoded per app. + * The app owns parsing (init), keybindings (reduce), placement (render). */ +export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({ + help: app.help, + name: app.id, run: (arg, ctx) => { - const err = launchWidget(name, arg) + const err = launchWidget(app.id, arg) if (err) { ctx.transcript.sys(err) } } -}) +})) export const debugCommands: SlashCommand[] = [ - widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'), - widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'), - widgetCommand('weather', 'current conditions with themed ASCII art (wttr.in)'), + ...widgetAppCommands, { help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)', diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3efa2c203c15..d6a2cefacdb8 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -22,7 +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 { ActiveWidgetSlot, AmbientDock } from '../sdk/host.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -442,6 +442,7 @@ const ComposerPane = memo(function ComposerPane({ {!composer.empty && !ui.sid && ⚕ {ui.status}} + ) diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index b3e31696ab6c..5e0dc33c540b 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -5,6 +5,24 @@ import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { CompletionResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' +import { listWidgetApps } from '../sdk/registry.js' + +/** Client-side widget apps live in the TUI's registry, not the gateway — so + * `/` completions merge their title/metadata here. Registry-driven: a new + * app surfaces automatically, no hardcoded lists on either side. */ +export function mergeWidgetAppItems(input: string, items: CompletionItem[]): CompletionItem[] { + // Only complete the command NAME position (no args typed yet). + if (input.includes(' ')) { + return items + } + + const local = listWidgetApps() + .filter(app => `/${app.id}`.startsWith(input.toLowerCase())) + .filter(app => !items.some(item => item.text === `/${app.id}`)) + .map(app => ({ display: `/${app.id}`, meta: app.help, text: `/${app.id}` })) + + return [...items, ...local] +} const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ @@ -85,7 +103,10 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const r = asRpcResult(raw) - setCompletions(r?.items ?? []) + const items = + request.method === 'complete.slash' ? mergeWidgetAppItems(input, r?.items ?? []) : (r?.items ?? []) + + setCompletions(items) setCompIdx(0) setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom) }) diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 896a62c2dce5..7f89cae93b74 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -35,6 +35,7 @@ const defaultBody = (zone: OverlayZone) => export const dialogTestApp = defineWidgetApp({ id: 'dialog-test', + help: 'open a sample dialog overlay with a faked backdrop', usage: USAGE, init(arg) { diff --git a/ui-tui/src/sdk/apps/gridTest.tsx b/ui-tui/src/sdk/apps/gridTest.tsx index c51ef937bde9..891931bee5ea 100644 --- a/ui-tui/src/sdk/apps/gridTest.tsx +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -89,6 +89,7 @@ function reduceStreams(grid: GridTestState, { ch, key }: WidgetInput): GridTestS export const gridTestApp = defineWidgetApp({ id: 'grid-test', + help: 'open an interactive widget-grid demo overlay', usage: USAGE, init(arg) { diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index 3a39a104ac78..34037745f223 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -3,4 +3,5 @@ export { dialogTestApp } from './dialogTest.js' export { gridTestApp } from './gridTest.js' export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js' +export { tickerApp, type TickerState } from './ticker.js' export { weatherApp, type WeatherState } from './weather.js' diff --git a/ui-tui/src/sdk/apps/ticker.tsx b/ui-tui/src/sdk/apps/ticker.tsx new file mode 100644 index 000000000000..6c6aba70867d --- /dev/null +++ b/ui-tui/src/sdk/apps/ticker.tsx @@ -0,0 +1,99 @@ +import { Box, Text } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import { Dialog } from '../../components/overlay.js' +import type { Theme } from '../../theme.js' +import { defineWidgetApp } from '../registry.js' +import { isCtrl } from '../types.js' + +/** + * Ticker — the animated ambient reference app: a fake 1-pip chart that + * random-walks a price and draws a live block sparkline, streams-demo style + * (the component owns its animation; app state is just the symbol). + */ + +const USAGE = 'usage: /ticker [symbol]' +const BLOCKS = '▁▂▃▄▅▆▇█' +const POINTS = 26 +const TICK_MS = 250 +const PIP = 0.0001 + +export interface TickerState { + symbol: string +} + +const spark = (series: number[]): string => { + const min = Math.min(...series) + const span = Math.max(...series) - min || 1 + + return series.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / span) * BLOCKS.length))]).join('') +} + +function Chart({ symbol, t }: { symbol: string; t: Theme }) { + const [series, setSeries] = useState(() => { + const seed = 1.1 + Math.random() * 0.4 + const out = [seed] + + while (out.length < POINTS) { + out.push(out.at(-1)! + (Math.random() - 0.5) * 4 * PIP) + } + + return out + }) + + useEffect(() => { + const id = setInterval( + () => setSeries(prev => [...prev.slice(1), prev.at(-1)! + (Math.random() - 0.5) * 4 * PIP]), + TICK_MS + ) + + return () => clearInterval(id) + }, []) + + const price = series.at(-1)! + const delta = price - series.at(-2)! + const up = delta >= 0 + const dir = up ? t.color.ok : t.color.error + + return ( + + + + {symbol} + + {price.toFixed(4)} + + {up ? '▲' : '▼'} + {Math.abs(delta / PIP).toFixed(1)}p + + + {spark(series)} + + ) +} + +export const tickerApp = defineWidgetApp({ + id: 'ticker', + help: 'fake 1-pip chart with a live sparkline', + mode: 'ambient', + usage: USAGE, + + init(arg) { + const symbol = (arg.trim().split(/\s+/)[0] || 'HRMS').toUpperCase().slice(0, 8) + + return { symbol } + }, + + // Never receives input while ambient; contract-complete for modal reuse. + reduce(state, { ch, key }) { + return key.escape || ch === 'q' || isCtrl(key, ch, 'c') ? null : state + }, + + render({ state, t }) { + return ( + + + + ) + } +}) diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx index 8f01a259dce5..daee3d482610 100644 --- a/ui-tui/src/sdk/apps/weather.tsx +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -1,6 +1,6 @@ import { Box, Text } from '@hermes/ink' -import { Dialog, Overlay } from '../../components/overlay.js' +import { Dialog } from '../../components/overlay.js' import type { Theme } from '../../theme.js' import { updateWidget } from '../host.js' import { defineWidgetApp } from '../registry.js' @@ -120,6 +120,8 @@ function load(location: string): void { export const weatherApp = defineWidgetApp({ id: 'weather', + help: 'current conditions with themed ASCII art (wttr.in)', + mode: 'ambient', usage: USAGE, init(arg) { @@ -144,18 +146,18 @@ export const weatherApp = defineWidgetApp({ return state }, + // Ambient: renders IN the dock (host owns placement) — a compact card + // that sits above the status bar while the composer stays live. render({ cols, state, t }) { const { phase } = state const title = phase.kind === 'ready' ? phase.report.area : 'Weather' return ( - - - {phase.kind === 'loading' && fetching wttr.in…} - {phase.kind === 'error' && {phase.message}} - {phase.kind === 'ready' && } - - + + {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'error' && {phase.message}} + {phase.kind === 'ready' && } + ) } }) diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index c851e58e18c7..72713f23b2bd 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -1,4 +1,5 @@ import { useStdout } from '@hermes/ink' +import { Box } from '@hermes/ink' import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' @@ -6,17 +7,26 @@ 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' +import type { ActiveWidget, 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. + * The widget-app host. Core integrates through exactly four touchpoints: + * launch (slash commands), dispatch (the input pipeline), the MODAL render + * slot (viewport-level), and the AMBIENT dock (in-flow, above the status + * bar). Everything else — state shape, keybindings, presentation — belongs + * to the app. */ +const isAmbient = (app: WidgetApp) => app.mode === 'ambient' + +const withoutApp = (dock: ActiveWidget[], id: string) => dock.filter(active => active.appId !== id) + +const dockWith = (dock: ActiveWidget[], entry: ActiveWidget) => [...withoutApp(dock, entry.appId), entry] + /** Launch by id. Returns null on success, a printable error/usage line on - * refusal — the caller owns the transcript. */ + * refusal — the caller owns the transcript. Relaunching a DOCKED ambient + * app (with no new argument) toggles it out of the dock — ambient apps + * capture no input, so the command is their only dismissal. */ export function launchWidget(id: string, arg = ''): null | string { const app = getWidgetApp(id) @@ -24,30 +34,64 @@ export function launchWidget(id: string, arg = ''): null | string { return `unknown widget app: ${id}` } + if (isAmbient(app)) { + const dock = $overlayState.get().ambient + + if (dock.some(active => active.appId === id) && !arg.trim()) { + patchOverlayState({ ambient: withoutApp(dock, id) }) + + return null + } + } + const state = app.init(arg) if (state === null) { return app.usage ?? `usage: /${id}` } - patchOverlayState({ widget: { appId: id, state } }) + if (isAmbient(app)) { + patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: id, state }) }) + } else { + patchOverlayState({ widget: { appId: id, state } }) + } return null } +/** Close the MODAL app. Ambient apps dismiss via their launch toggle, so a + * modal's Esc can't collaterally clear the dock. */ 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). */ + * stack each other (the host swaps the active modal app). */ export function openWidget(app: WidgetApp, state: S): void { - patchOverlayState({ widget: { appId: app.id, state } }) + if (isAmbient(app as WidgetApp)) { + patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: app.id, state }) }) + } else { + patchOverlayState({ widget: { appId: app.id, state } }) + } } -/** Async state delivery: patch the app's state ONLY while it is still the - * active widget — a late fetch resolution can never resurrect a closed app - * or clobber a different one. This is how data-backed apps land results +/** Async state delivery: patch the app's state ONLY while it is still active + * in its slot — a late fetch resolution can never resurrect a closed app or + * clobber a different one. This is how data-backed apps land results * outside the input pipeline (see the weather reference app). */ export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { + if (isAmbient(app as WidgetApp)) { + const dock = $overlayState.get().ambient + + if (!dock.some(active => active.appId === app.id)) { + return + } + + patchOverlayState({ + ambient: dock.map(active => (active.appId === app.id ? { appId: app.id, state: fn(active.state as S) } : active)) + }) + + return + } + const active = $overlayState.get().widget if (active?.appId !== app.id) { @@ -57,8 +101,9 @@ export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } }) } -/** Feed one keypress to the active app. Returns true when a widget is active - * (apps swallow every key while open — the overlay is modal). */ +/** Feed one keypress to the active MODAL app (ambient apps capture no + * input). Returns true when a modal app is active — apps swallow every key + * while open. */ export function dispatchWidgetInput(input: WidgetInput): boolean { const active = $overlayState.get().widget @@ -85,7 +130,13 @@ export function dispatchWidgetInput(input: WidgetInput): boolean { return true } -/** Render slot for the active app — viewport-level, so apps can anchor +const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => { + const app = getWidgetApp(active.appId) + + return app ? app.render({ ...ctx, state: active.state as never }) : null +} + +/** Render slot for the MODAL app — viewport-level, so it can anchor * `Overlay` zones and backdrops against the full terminal. */ export function ActiveWidgetSlot(): ReactNode { const overlay = useStore($overlayState) @@ -96,16 +147,28 @@ export function ActiveWidgetSlot(): ReactNode { return null } - const app = getWidgetApp(overlay.widget.appId) + return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }) +} - if (!app) { +/** The ambient dock: in-FLOW (never floats over the transcript), + * right-aligned, sitting directly above the status bar — GUI-style + * "widgets that just sit there" while the composer stays live. */ +export function AmbientDock(): ReactNode { + const overlay = useStore($overlayState) + const t = useStore($uiTheme) + const { stdout } = useStdout() + + if (!overlay.ambient.length) { return null } - return app.render({ - cols: stdout?.columns ?? 80, - rows: stdout?.rows ?? 24, - state: overlay.widget.state as never, - t - }) + const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } + + return ( + + {overlay.ambient.map(active => ( + {renderApp(active, ctx)} + ))} + + ) } diff --git a/ui-tui/src/sdk/registry.ts b/ui-tui/src/sdk/registry.ts index f3c56129fa01..2a1264987316 100644 --- a/ui-tui/src/sdk/registry.ts +++ b/ui-tui/src/sdk/registry.ts @@ -12,4 +12,6 @@ export function defineWidgetApp(app: WidgetApp): WidgetApp { export const getWidgetApp = (id: string): undefined | WidgetApp => apps.get(id) -export const listWidgetApps = (): string[] => [...apps.keys()].sort() +/** All registered apps, id-sorted — the registry IS the catalog: slash + * commands and `/` completions derive from it, nothing is hardcoded. */ +export const listWidgetApps = (): WidgetApp[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id)) diff --git a/ui-tui/src/sdk/types.ts b/ui-tui/src/sdk/types.ts index 264c805017d6..e19d6f3ce92e 100644 --- a/ui-tui/src/sdk/types.ts +++ b/ui-tui/src/sdk/types.ts @@ -35,6 +35,14 @@ export interface WidgetRenderCtx { */ export interface WidgetApp { id: string + /** One-line description — surfaces in `/` completions and command help. */ + help: string + /** + * `modal` (default): owns every keypress, blocks the composer. + * `ambient`: glanceable panel — no input capture, no blocking; launching + * the same id again toggles it closed. + */ + mode?: 'ambient' | 'modal' init(arg: string): null | S reduce(state: S, input: WidgetInput): null | S render(ctx: WidgetRenderCtx): ReactNode From ca0b1b130c9ab997eb8115e3f9f70bf336ae1610 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 22:44:22 -0500 Subject: [PATCH 4/8] =?UTF-8?q?feat(ui-tui):=20self-authored=20widgets=20?= =?UTF-8?q?=E2=80=94=20user-widget=20loader,=20hot-load,=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes can write its own widgets: a loader discovers $HERMES_HOME/tui-widgets/*.mjs, fs.watch hot-loads them the moment they land (no restart), and a tui-widgets skill teaches the agent the contract and the openWidget-at-register auto-open recipe. Load/error/remove events announce themselves in the transcript; a lazy intro skeleton covers the first paint. --- skills/productivity/tui-widgets/SKILL.md | 107 ++++++++++ .../tui-widgets/templates/clock.mjs | 51 +++++ ui-tui/src/__tests__/userWidgets.test.ts | 67 ++++++ ui-tui/src/app/createSlashHandler.ts | 15 ++ ui-tui/src/app/slash/commands/debug.ts | 16 ++ ui-tui/src/app/useMainApp.ts | 21 ++ ui-tui/src/sdk/apps/index.ts | 8 +- ui-tui/src/sdk/host.tsx | 4 +- ui-tui/src/sdk/index.ts | 1 + ui-tui/src/sdk/registry.ts | 3 + ui-tui/src/sdk/userWidgets.ts | 202 ++++++++++++++++++ 11 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 skills/productivity/tui-widgets/SKILL.md create mode 100644 skills/productivity/tui-widgets/templates/clock.mjs create mode 100644 ui-tui/src/__tests__/userWidgets.test.ts create mode 100644 ui-tui/src/sdk/userWidgets.ts diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md new file mode 100644 index 000000000000..8c19acaac862 --- /dev/null +++ b/skills/productivity/tui-widgets/SKILL.md @@ -0,0 +1,107 @@ +--- +name: tui-widgets +description: Author live widget apps for the Hermes TUI dock. +version: 1.0.0 +author: Hermes Agent +license: MIT +metadata: + hermes: + tags: [tui, widgets, sdk, ui] + category: productivity +--- + +# TUI Widgets Skill + +Author widget apps for the Hermes TUI (`hermes --tui`): glanceable ambient +panels docked above the status bar, or modal overlays that own the keyboard. +Widgets are plain ESM files the TUI loads at startup — no build step, no +repo changes. This skill does not cover desktop-app or web-dashboard +widgets. + +## When to Use + +- The user asks for a live panel in the TUI (ticker, clock, countdown, + status card, API-backed readout). +- The user wants a custom modal tool (picker, calculator, viewer) bound to + a slash command. + +## Prerequisites + +- The TUI must be in use (`hermes --tui`). Widgets do not render in the + classic CLI or messaging platforms. +- Network-backed widgets need whatever credentials their API needs; fetch + failures must land as an error phase, never a crash. + +## How to Run + +1. Use `write_file` to create `~/.hermes/tui-widgets/.mjs` (see + `templates/clock.mjs` for a complete working widget). +2. If the TUI is running it hot-loads the file within ~a second (the + widgets directory is watched); `/widgets-reload` forces a rescan. +3. The widget's id becomes its slash command automatically (`/`), with + its `help` in the `/` completion popover. No other registration exists. + +## Quick Reference + +A widget file default-exports `register(sdk)`: + +```js +export default function register(sdk) { + const { Box, Text, defineWidgetApp, h } = sdk + + defineWidgetApp({ + id: 'clock', // slash command name + help: 'live clock in the dock', // `/` completion metadata + mode: 'ambient', // 'ambient' docks; 'modal' takes input + init: arg => ({ label: arg.trim() || 'UTC' }), // null = print usage + reduce: (state, { ch, key }) => (key.escape || ch === 'q' ? null : state), + render: ({ state, t }) => h(sdk.Dialog, { width: 24 }, h(Text, { color: t.color.label }, state.label)) + }) +} +``` + +`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`, +`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`, +`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`. + +Contract essentials: + +- `mode: 'ambient'` — docks above the status bar, captures no input, the + command toggles it; `render` returns a CARD (usually `Dialog`), never + `Overlay`. +- `mode: 'modal'` (default) — owns every keypress; `reduce` returns next + state, the same reference to swallow a key, or `null` to close; `render` + wraps content in `Overlay` for placement. +- Async data: fire the fetch from `init`, land results with + `sdk.updateWidget(app, fn)` — it no-ops if the widget was closed, so a + late reply can never resurrect it. +- Animation: own a timer inside a component via `React.useState` + + `React.useEffect` (see the template); keep intervals ≥ 250ms. +- Colors: ALWAYS theme tones (`t.color.primary/label/muted/ok/error/…`), + never hardcoded hexes — widgets must survive `/skin` and light/dark. + +## Procedure + +1. Pick `id`, `mode`, and the state shape; keep state serializable. +2. Write the file from the template; wire data via `init` + `updateWidget`. +3. `/` to launch (hot-loaded on write); relaunch `/` to dismiss an + ambient widget. +4. Iterate: edit the file — it hot-reloads on save (last-writer-wins, the + fresh definition shadows the old one). Relaunch `/` to remount. + +## Pitfalls + +- No JSX and no bare imports in `.mjs` — everything comes from the `sdk` + parameter; `h(...)` builds elements. +- Don't ship a modal without a close path (`Esc`/`q` returning `null`). +- Ambient widgets must stay small (≤ ~6 rows) — the dock sits between the + transcript and the status bar. +- A thrown `register()` is logged and skipped; check + `~/.hermes/logs/tui_gateway_crash.log` if a widget never appears. + +## Verification + +Run `/widgets-reload` — the transcript line must list the file under +`loaded:`. Then `/`: an ambient widget appears docked right, above the +status bar, while the composer keeps accepting input; `/` again removes +it. diff --git a/skills/productivity/tui-widgets/templates/clock.mjs b/skills/productivity/tui-widgets/templates/clock.mjs new file mode 100644 index 000000000000..fea22af3499f --- /dev/null +++ b/skills/productivity/tui-widgets/templates/clock.mjs @@ -0,0 +1,51 @@ +/** + * Reference user widget: a live clock docked above the status bar. + * Copy to ~/.hermes/tui-widgets/clock.mjs, then `/widgets-reload` and `/clock`. + */ +export default function register(sdk) { + const { Box, Dialog, React, Text, defineWidgetApp, h } = sdk + + function Face({ label, t }) { + const [now, setNow] = React.useState(() => new Date()) + + React.useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000) + + return () => clearInterval(id) + }, []) + + return h( + Box, + { columnGap: 1, flexDirection: 'row' }, + h(Text, { bold: true, color: t.color.label }, label), + h(Text, { color: t.color.text }, now.toLocaleTimeString('en-GB', { hour12: false, timeZone: label === 'local' ? undefined : label })) + ) + } + + defineWidgetApp({ + id: 'clock', + help: 'live clock in the dock (arg: IANA timezone)', + mode: 'ambient', + usage: 'usage: /clock [timezone] e.g. /clock UTC · /clock Asia/Tokyo', + + init(arg) { + const label = arg.trim() || 'local' + + try { + new Date().toLocaleTimeString('en-GB', { timeZone: label === 'local' ? undefined : label }) + } catch { + return null + } + + return { label } + }, + + reduce(state, { ch, key }) { + return key.escape || ch === 'q' ? null : state + }, + + render({ state, t }) { + return h(Dialog, { width: 30 }, h(Face, { label: state.label, t })) + } + }) +} diff --git a/ui-tui/src/__tests__/userWidgets.test.ts b/ui-tui/src/__tests__/userWidgets.test.ts new file mode 100644 index 000000000000..59aa6f71c313 --- /dev/null +++ b/ui-tui/src/__tests__/userWidgets.test.ts @@ -0,0 +1,67 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { beforeEach, describe, expect, it } from 'vitest' + +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { launchWidget } from '../sdk/host.js' +import { getWidgetApp } from '../sdk/registry.js' +import { loadUserWidgets } from '../sdk/userWidgets.js' + +const WIDGET = ` +export default function register(sdk) { + sdk.defineWidgetApp({ + id: 'test-user-widget', + help: 'from disk', + mode: 'ambient', + init: arg => ({ arg }), + reduce: state => state, + render: ({ state, t }) => sdk.h(sdk.Text, { color: t.color.label }, state.arg) + }) +} +` + +beforeEach(() => resetOverlayState()) + +describe('user widget loading', () => { + it('missing directory is a clean no-op', async () => { + const result = await loadUserWidgets(join(tmpdir(), 'definitely-missing-widgets-dir')) + + expect(result).toEqual({ added: [], errors: [], loaded: [], removed: [] }) + }) + + it('loads .mjs from disk, registers, dispatches, and reports broken files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-')) + + await writeFile(join(dir, 'good.mjs'), WIDGET) + await writeFile(join(dir, 'broken.mjs'), 'export default 42') + await writeFile(join(dir, 'ignored.txt'), 'not a widget') + + const result = await loadUserWidgets(dir) + + expect(result.loaded).toEqual(['good.mjs']) + expect(result.added).toEqual(['test-user-widget']) + expect(result.errors).toMatchObject([{ file: 'broken.mjs' }]) + + // Registered like any built-in: catalog metadata + launchable. + expect(getWidgetApp('test-user-widget')).toMatchObject({ help: 'from disk', mode: 'ambient' }) + expect(launchWidget('test-user-widget', 'hi')).toBeNull() + expect(getOverlayState().ambient).toMatchObject([{ appId: 'test-user-widget', state: { arg: 'hi' } }]) + }) + + it('a deleted file unregisters its apps on the next scan', async () => { + const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-')) + const file = join(dir, 'gone.mjs') + + await writeFile(file, WIDGET.replace('test-user-widget', 'soon-gone')) + await loadUserWidgets(dir) + expect(getWidgetApp('soon-gone')).toBeDefined() + + await rm(file) + const result = await loadUserWidgets(dir) + + expect(result.removed).toEqual(['soon-gone']) + expect(getWidgetApp('soon-gone')).toBeUndefined() + }) +}) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index a164511c7585..dc798e842c6d 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,6 +1,8 @@ import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' +import { launchWidget } from '../sdk/host.js' +import { getWidgetApp } from '../sdk/registry.js' import type { SlashHandlerContext } from './interfaces.js' import { findSlashCommand } from './slash/registry.js' @@ -45,6 +47,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return true } + // Registry-first fallback: widget apps registered AFTER the static + // command table was built (user widgets from $HERMES_HOME/tui-widgets, + // /widgets-reload) dispatch straight off the live registry. + if (getWidgetApp(parsed.name)) { + const err = launchWidget(parsed.name, parsed.arg) + + if (err) { + sys(err) + } + + return true + } + if (catalog?.canon) { const needle = `/${parsed.name}`.toLowerCase() const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1] diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index 7fa0b3797c71..e93208f1b8e5 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -6,6 +6,7 @@ import { terminalBackgroundHex } from '@hermes/ink' import { formatBytes, performHeapDump } from '../../../lib/memory.js' import { launchWidget } from '../../../sdk/host.js' import { listWidgetApps } from '../../../sdk/registry.js' +import { loadUserWidgets } from '../../../sdk/userWidgets.js' import { detectLightMode } from '../../../theme.js' import { getUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' @@ -28,6 +29,21 @@ export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({ export const debugCommands: SlashCommand[] = [ ...widgetAppCommands, + { + help: 'rescan $HERMES_HOME/tui-widgets and (re)register user widget apps', + name: 'widgets-reload', + run: (_arg, ctx) => { + void loadUserWidgets().then(({ errors, loaded }) => { + const parts = [ + loaded.length ? `loaded: ${loaded.join(', ')}` : 'no user widgets found', + ...errors.map(e => `${e.file}: ${e.message}`) + ] + + ctx.transcript.sys(`widgets — ${parts.join(' · ')}`) + }) + } + }, + { help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)', name: 'heapdump', diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 80a863c45bf5..586d30716e42 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -38,6 +38,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' +import { onUserWidgets } from '../sdk/userWidgets.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' @@ -435,6 +436,26 @@ export function useMainApp(gw: GatewayClient) { const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) + // Hot-loaded user widgets announce themselves — a silently-registered + // widget is indistinguishable from a failed one. Errors surface too. + useEffect( + () => + onUserWidgets(({ added, errors, removed }) => { + for (const id of added) { + sys(`widget /${id} is live — type /${id} to open`) + } + + for (const id of removed) { + sys(`widget /${id} removed (file deleted)`) + } + + for (const err of errors) { + sys(`widget ${err.file} failed to load: ${err.message}`) + } + }), + [sys] + ) + const page = useCallback( (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), [] diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index 34037745f223..35bd0587f649 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -1,5 +1,11 @@ /** Reference apps. Importing this module registers them (defineWidgetApp - * runs at module load) — appLayout imports it once at startup. */ + * runs at module load) — appLayout imports it once at startup. User widgets + * from $HERMES_HOME/tui-widgets ride the same import (async, non-fatal). */ +import { loadUserWidgets, watchUserWidgets } from '../userWidgets.js' + +void loadUserWidgets() +watchUserWidgets() + 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 index 72713f23b2bd..197f77e71ee0 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -164,8 +164,10 @@ export function AmbientDock(): ReactNode { const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } + // paddingRight keeps card borders off the terminal's last column — an + // exact-edge border char trips pending-wrap and reads as a clipped border. return ( - + {overlay.ambient.map(active => ( {renderApp(active, ctx)} ))} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 1c2336cfc3c3..772948e57695 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -46,3 +46,4 @@ export type { Theme, ThemeColors } from '../theme.js' export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js' export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js' export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js' +export { loadUserWidgets, type UserWidgetLoadResult, widgetSdk, type WidgetSdk } from './userWidgets.js' diff --git a/ui-tui/src/sdk/registry.ts b/ui-tui/src/sdk/registry.ts index 2a1264987316..3a94af11357a 100644 --- a/ui-tui/src/sdk/registry.ts +++ b/ui-tui/src/sdk/registry.ts @@ -12,6 +12,9 @@ export function defineWidgetApp(app: WidgetApp): WidgetApp { export const getWidgetApp = (id: string): undefined | WidgetApp => apps.get(id) +/** Unregister (user-widget file deleted). Built-ins never call this. */ +export const removeWidgetApp = (id: string): boolean => apps.delete(id) + /** All registered apps, id-sorted — the registry IS the catalog: slash * commands and `/` completions derive from it, nothing is hardcoded. */ export const listWidgetApps = (): WidgetApp[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id)) diff --git a/ui-tui/src/sdk/userWidgets.ts b/ui-tui/src/sdk/userWidgets.ts new file mode 100644 index 000000000000..f423204e1f88 --- /dev/null +++ b/ui-tui/src/sdk/userWidgets.ts @@ -0,0 +1,202 @@ +import { watch } from 'fs' +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import { pathToFileURL } from 'url' + +import { Box, Text } from '@hermes/ink' +import * as React from 'react' + +import { Dialog, Overlay } from '../components/overlay.js' +import { GridAreas, WidgetGrid } from '../components/widgetGrid.js' +import { recordParentLifecycle } from '../lib/parentLog.js' + +import { openWidget, updateWidget } from './host.js' +import { defineWidgetApp, listWidgetApps, removeWidgetApp } from './registry.js' +import { isCtrl } from './types.js' + +/** + * User widget apps — Hermes authors its own TUI widgets, mirroring the + * Python plugin contract: drop `.mjs` into `$HERMES_HOME/tui-widgets/`, + * default-export `register(sdk)`, and the app surfaces in `/` completions + * and dispatch automatically (the registry is the catalog). Plain ESM so the + * production bundle can import it — no bundler, no JSX; `sdk.h` is + * React.createElement. + * + * Trust model matches `~/.hermes/plugins/`: files under HERMES_HOME execute + * with the TUI's privileges. Load errors log and skip — a broken widget + * never takes the TUI down. + */ + +/** Everything a user widget may touch, passed INTO its register() — user + * files have no resolvable import path to the bundle. */ +export const widgetSdk = { + Box, + Dialog, + GridAreas, + Overlay, + React, + Text, + WidgetGrid, + defineWidgetApp, + h: React.createElement, + isCtrl, + openWidget, + updateWidget +} as const + +export type WidgetSdk = typeof widgetSdk + +const widgetsDir = () => join(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes'), 'tui-widgets') + +export interface UserWidgetLoadResult { + /** App ids newly registered by this scan. */ + added: string[] + errors: { file: string; message: string }[] + loaded: string[] + /** App ids unregistered because their file disappeared. */ + removed: string[] +} + +/** Which app ids each user file registered — the delete-sync source of + * truth (file gone on the next scan ⇒ its apps unregister). */ +const fileApps = new Map() + +const listeners = new Set<(result: UserWidgetLoadResult) => void>() + +/** Subscribe to scan results — the app layer announces loads in the + * transcript so a hot-loaded widget is VISIBLY live (silent success is + * indistinguishable from failure). */ +export function onUserWidgets(listener: (result: UserWidgetLoadResult) => void): () => void { + listeners.add(listener) + + return () => listeners.delete(listener) +} + +/** Scan + import + register, diffing the registry per file. Cache-busted so + * edits reload without restarting the TUI (last-writer-wins shadows stale + * definitions). Files that vanished unregister their apps. */ +export async function loadUserWidgets(dir = widgetsDir()): Promise { + const result: UserWidgetLoadResult = { added: [], errors: [], loaded: [], removed: [] } + + let files: string[] = [] + + try { + files = (await readdir(dir)).filter(f => f.endsWith('.mjs')).sort() + } catch { + // No directory: fall through so previously-loaded files still delete-sync. + } + + for (const [file, ids] of fileApps) { + if (!files.includes(file)) { + fileApps.delete(file) + + for (const id of ids) { + if (removeWidgetApp(id)) { + result.removed.push(id) + } + } + } + } + + for (const file of files) { + const before = new Set(listWidgetApps().map(app => app.id)) + + try { + const mod = (await import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`)) as { + default?: (sdk: WidgetSdk) => void + } + + if (typeof mod.default !== 'function') { + throw new Error('default export must be register(sdk)') + } + + mod.default(widgetSdk) + result.loaded.push(file) + + const ids = listWidgetApps() + .map(app => app.id) + .filter(id => !before.has(id)) + + // Re-registrations of existing ids keep their prior file attribution. + if (ids.length) { + fileApps.set(file, ids) + result.added.push(...ids) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + + result.errors.push({ file, message }) + recordParentLifecycle(`user widget ${file} failed to load: ${message}`) + } + } + + if (result.added.length) { + recordParentLifecycle(`user widgets registered: ${result.added.join(', ')}`) + } + + for (const listener of listeners) { + listener(result) + } + + return result +} + +let watching = false + +/** Generative-UI hot loading: watch the widgets directory and re-scan on + * every change, so a widget Hermes writes appears within ~a second — no + * `/widgets-reload`, no restart (GUI parity). Debounced (editors and + * write_file emit bursts); polls until the directory exists so the very + * first widget ever written also hot-loads. */ +export function watchUserWidgets(dir = widgetsDir()): void { + if (watching) { + return + } + + watching = true + + let timer: NodeJS.Timeout | undefined + + const attach = () => { + try { + const watcher = watch(dir, () => { + clearTimeout(timer) + timer = setTimeout(() => void loadUserWidgets(dir), 300) + timer.unref?.() + }) + + watcher.unref?.() + + return true + } catch { + return false // directory doesn't exist yet + } + } + + if (!attach()) { + // Event-driven first-creation: watch the PARENT for the widgets dir to + // appear, attach + scan the instant it does. The very first widget a + // user (or Hermes) ever writes must hot-load too — a 10s poll here read + // as "requires a restart" in live use. + try { + const parent = watch(dirname(dir), () => { + if (attach()) { + parent.close() + void loadUserWidgets(dir) + } + }) + + parent.unref?.() + } catch { + const poll = setInterval(() => { + if (attach()) { + clearInterval(poll) + void loadUserWidgets(dir) + } + }, 2_000) + + poll.unref?.() + } + } +} From ed93d6afe028fa1ce952b754a42fdee87fc02da5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:13:58 -0500 Subject: [PATCH 5/8] =?UTF-8?q?feat(ui-tui):=20widget=20primitives=20?= =?UTF-8?q?=E2=80=94=20charts,=20accordion,=20shimmer,=20stable=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable render primitives the SDK exposes to widget authors: sparkline/gauge/ hbars chart helpers (dimension-stable so live updates never resize the card), an Accordion for expand/collapse sections, animated shimmer loaders, and a streams demo that no longer reserves a phantom icon column on unfocused titles. --- skills/productivity/tui-widgets/SKILL.md | 30 ++++++++- ui-tui/src/__tests__/charts.test.ts | 47 +++++++++++++ ui-tui/src/components/accordion.tsx | 58 ++++++++++++++++ ui-tui/src/components/branding.tsx | 56 +++++----------- ui-tui/src/components/gridStreamsDemo.tsx | 38 ++--------- ui-tui/src/lib/charts.ts | 80 +++++++++++++++++++++++ ui-tui/src/sdk/apps/ticker.tsx | 11 +--- ui-tui/src/sdk/apps/weather.tsx | 18 ++++- ui-tui/src/sdk/index.ts | 5 +- ui-tui/src/sdk/userWidgets.ts | 13 +++- 10 files changed, 269 insertions(+), 87 deletions(-) create mode 100644 ui-tui/src/__tests__/charts.test.ts create mode 100644 ui-tui/src/components/accordion.tsx create mode 100644 ui-tui/src/lib/charts.ts diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md index 8c19acaac862..a18c81460a5f 100644 --- a/skills/productivity/tui-widgets/SKILL.md +++ b/skills/productivity/tui-widgets/SKILL.md @@ -62,7 +62,35 @@ export default function register(sdk) { `sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`, `React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`, -`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`. +`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`, and loaders `Shimmer`, +`ShimmerRows`, `useShimmerPhase` — use `ShimmerRows` for loading phases +instead of a bare "loading…" line. + +Expand/collapse: `sdk.Accordion` — the same primitive the session panel's +tool/skill sections use. `h(Accordion, { t, title: 'details', count: 3, +defaultOpen: false }, body)` toggles on CLICK (works in ambient widgets, +which receive no keys); modal apps may pass `open` + `onToggle` to drive it +from reducer state instead. + +Stable sizing (cards must NEVER resize while ticking): + +- Give `Dialog` an explicit `width`; charts already return exactly the + `width` you ask for (short series pad-left while history warms up). +- Pad dynamic numbers: `String(v).padStart(6)` — `51 ms` → `112 ms` must + not change the line length. +- Keep row counts constant per phase; swap content, not structure. + +Charts (pure string builders — color the result with theme tones): + +- `sdk.sparkline(series, width?)` → `▂▃▅▇█▆` one-row trend +- `sdk.sparkRows(series, width, rows)` → multi-row column chart (top line + first) — the mission-control panel look; taller cells gain resolution +- `sdk.gauge(ratio, width)` → `█████░░░` fill bar for a 0..1 value +- `sdk.hbars(values, width)` → horizontal bar chart, one bar per value, + eighth-block tips, scaled to the max + +Keep a rolling series in component state (push per tick, cap ~120 samples) +and render `sparkRows` for dashboard panels, `sparkline` for one-liners. Contract essentials: diff --git a/ui-tui/src/__tests__/charts.test.ts b/ui-tui/src/__tests__/charts.test.ts new file mode 100644 index 000000000000..0f3880518ab4 --- /dev/null +++ b/ui-tui/src/__tests__/charts.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js' + +describe('chart primitives', () => { + it('sparkline spans the block ramp and respects width', () => { + const line = sparkline([0, 1, 2, 3, 4, 5, 6, 7], 8) + + expect(line).toBe('▁▂▃▄▅▆▇█') + expect(sparkline([1, 2, 3, 4], 2)).toHaveLength(2) // window = last N + }) + + it('is dimension-stable: short/empty series pad to exactly width', () => { + // Warm-up must never resize the card — latest sample pins right. + expect(sparkline([5], 6)).toHaveLength(6) + expect(sparkline([5], 6).endsWith('▁')).toBe(true) // flat series → bottom block, right-pinned + expect(sparkline([], 6)).toBe(' ') + expect(sparkRows([7], 5, 2).every(row => row.length === 5)).toBe(true) + expect(sparkRows([], 5, 2)).toEqual([' ', ' ']) + expect(hbars([1, 4], 8).every(bar => bar.length === 8)).toBe(true) + }) + + it('sparkRows partitions each column across rows (top line first)', () => { + const rows = sparkRows([0, 8, 4], 3, 2) + + expect(rows).toHaveLength(2) + expect(rows.every(r => r.length === 3)).toBe(true) + // Max value fills the top row cell; min value leaves it blank. + expect(rows[0]![1]).toBe('█') + expect(rows[0]![0]).toBe(' ') + }) + + it('gauge clamps and fills proportionally', () => { + expect(gauge(0.5, 8)).toBe('████░░░░') + expect(gauge(-1, 4)).toBe('░░░░') + expect(gauge(9, 4)).toBe('████') + }) + + it('hbars scales to the max with eighth-block tips', () => { + const [half, full] = hbars([4, 8], 8) + + expect(full).toBe('████████') + expect(half).toBe('████ ') + expect(hbars([3, 8], 8)[0]).toBe('███ ') // 3/8 of 8 cells, padded + expect(hbars([1, 2], 3)[0]).toMatch(/^█?[▏▎▍▌▋▊▉█] *$/) // fractional tip, padded + }) +}) diff --git a/ui-tui/src/components/accordion.tsx b/ui-tui/src/components/accordion.tsx new file mode 100644 index 000000000000..943259ee15bb --- /dev/null +++ b/ui-tui/src/components/accordion.tsx @@ -0,0 +1,58 @@ +import { Box, Text } from '@hermes/ink' +import { type ReactNode, useState } from 'react' + +import type { Theme } from '../theme.js' + +/** + * THE expand/collapse primitive — the session panel's tool/skill sections + * and widget-app accordions are the same component. Click the header to + * toggle (mouse works even in ambient widgets, which receive no keys); + * modal apps may instead drive `open` from reducer state (controlled). + * Uncontrolled by default: pass `defaultOpen` and forget it. + */ +export function Accordion({ + children, + count, + defaultOpen = false, + onToggle, + open, + suffix, + t, + title +}: { + children: ReactNode + count?: number + defaultOpen?: boolean + /** Controlled open state; omit for internal (click-toggled) state. */ + open?: boolean + onToggle?: () => void + suffix?: string + t: Theme + title: string +}) { + const [uncontrolled, setUncontrolled] = useState(defaultOpen) + const isOpen = open ?? uncontrolled + + const toggle = () => { + onToggle?.() + + if (open === undefined) { + setUncontrolled(v => !v) + } + } + + return ( + + + {isOpen ? '▾ ' : '▸ '} + + {title} + + {typeof count === 'number' ? ({count}) : null} + {suffix ? {suffix} : null} + + + {isOpen ? children : null} + + ) +} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index fcc5f6f130d1..18fdf3332c73 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -8,6 +8,7 @@ import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { PanelSection, SessionInfo } from '../types.js' +import { Accordion } from './accordion.js' import { ShimmerRows } from './loaders.js' import { WidgetGrid } from './widgetGrid.js' @@ -203,35 +204,6 @@ const SKELETON_ROWS: readonly (readonly [number, number])[] = [ [10, 13] ] -// ── Collapsible helpers ────────────────────────────────────────────── - -function CollapseToggle({ - count, - open, - suffix, - t, - title, - onToggle -}: { - count?: number - open: boolean - suffix?: string - t: Theme - title: string - onToggle: () => void -}) { - return ( - - {open ? '▾ ' : '▸ '} - - {title} - - {typeof count === 'number' ? ({count}) : null} - {suffix ? {suffix} : null} - - ) -} - // ── SessionPanel ───────────────────────────────────────────────────── const SKILLS_MAX = 8 @@ -431,49 +403,53 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) { {/* ── Tools (expanded by default) ── */} - setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" /> - {toolsOpen && toolsBody()} + setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools"> + {toolsBody()} + {/* ── Skills (collapsed by default) ── */} - setSkillsOpen(v => !v)} open={skillsOpen} suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined} t={t} title="Available Skills" - /> - {skillsOpen && skillsBody()} + > + {skillsBody()} + {/* ── System Prompt (collapsed by default) ── */} {sysPromptLen > 0 && ( - setSystemOpen(v => !v)} open={systemOpen} suffix={`— ${sysPromptLen.toLocaleString()} chars`} t={t} title="System Prompt" - /> - {systemOpen && systemBody()} + > + {systemBody()} + )} {/* ── MCP Servers (collapsed by default) ── */} {mcpServers.length > 0 && ( - setMcpOpen(v => !v)} open={mcpOpen} suffix="connected" t={t} title="MCP Servers" - /> - {mcpOpen && mcpBody()} + > + {mcpBody()} + )} diff --git a/ui-tui/src/components/gridStreamsDemo.tsx b/ui-tui/src/components/gridStreamsDemo.tsx index eaa1d2ec7d91..d94c85b957b2 100644 --- a/ui-tui/src/components/gridStreamsDemo.tsx +++ b/ui-tui/src/components/gridStreamsDemo.tsx @@ -1,6 +1,7 @@ import { Box, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useRef, useState } from 'react' +import { sparkRows } from '../lib/charts.js' import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js' import type { GridTestState } from '../sdk/apps/gridTestState.js' import type { Theme } from '../theme.js' @@ -18,8 +19,6 @@ const HEADER_ROWS = 3 const STREAM_ROWS = 3 const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6 -const SPARK_CHARS = '▁▂▃▄▅▆▇█' - interface StreamDef { id: string render: (inner: { height: number; t: Theme; width: number }) => ReactNode @@ -57,37 +56,6 @@ const useHistory = (tick: number, sample: () => number, cap = 240) => { return historyRef.current } -/** - * Render history as a column chart `rows` lines tall (top line first). Each - * column gets `rows * 8` vertical levels — full blocks below the value, a - * partial eighth-block at the value row, spaces above — so a promoted (taller) - * cell genuinely gains chart resolution rather than just whitespace. - */ -const sparkRows = (history: number[], width: number, rows: number): string[] => { - const window = history.slice(-Math.max(1, width)) - - if (!window.length) { - return Array.from({ length: rows }, () => '') - } - - const min = Math.min(...window) - const max = Math.max(...window) - const range = max - min || 1 - const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8))) - - return Array.from({ length: rows }, (_, lineIdx) => { - const rowFromBottom = rows - 1 - lineIdx - - return levels - .map(level => { - const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8)) - - return filled === 0 ? ' ' : SPARK_CHARS[filled - 1] - }) - .join('') - }) -} - // ── stream panels ─────────────────────────────────────────────────────────── const TOKEN_WORDS = ( @@ -311,8 +279,10 @@ function StreamPanel({ paddingX={1} width={cell.width} > + {/* No phantom icon column: unfocused titles sit flush left — the ▸ + appears (and shifts the title) only while focused. */} - {focused ? '▸ ' : ' '} + {focused ? '▸ ' : ''} {title} {main ? ' ·' : ''} diff --git a/ui-tui/src/lib/charts.ts b/ui-tui/src/lib/charts.ts new file mode 100644 index 000000000000..d9eba4f1a20f --- /dev/null +++ b/ui-tui/src/lib/charts.ts @@ -0,0 +1,80 @@ +/** + * Chart primitives — pure string builders (no React), THE charting layer for + * widget apps and demos alike. Everything returns plain strings the caller + * colors with theme tones; everything auto-scales to the series' min/max. + */ + +const BLOCKS = '▁▂▃▄▅▆▇█' + +const normalize = (series: number[], window: number): { min: number; range: number; window: number[] } => { + const view = series.slice(-Math.max(1, window)) + const min = Math.min(...view) + + return { min, range: Math.max(...view) - min || 1, window: view } +} + +/** One-row sparkline: `▂▃▅▇█▆…`, last `width` samples. ALWAYS exactly + * `width` cells — short series pad-left so the card never resizes while + * history warms up (latest sample stays pinned to the right edge). */ +export function sparkline(series: number[], width = series.length): string { + if (!series.length) { + return ' '.repeat(Math.max(0, width)) + } + + const { min, range, window } = normalize(series, width) + + return window + .map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / range) * BLOCKS.length))]) + .join('') + .padStart(width) +} + +/** + * Multi-row column chart, top line first — the streams-demo panel chart. + * Each column resolves to `rows * 8` vertical levels (full blocks below the + * value, a partial eighth-block at it), so taller cells genuinely gain + * resolution. + */ +export function sparkRows(series: number[], width: number, rows: number): string[] { + if (!series.length) { + return Array.from({ length: rows }, () => ' '.repeat(width)) + } + + const { min, range, window } = normalize(series, width) + const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8))) + + return Array.from({ length: rows }, (_, lineIdx) => { + const rowFromBottom = rows - 1 - lineIdx + + return levels + .map(level => { + const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8)) + + return filled === 0 ? ' ' : BLOCKS[filled - 1] + }) + .join('') + .padStart(width) // warm-up pads left: chart grows from the right, card never resizes + }) +} + +/** Horizontal fill gauge: `█████░░░` for a 0..1 ratio. */ +export function gauge(ratio: number, width: number): string { + const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width) + + return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled)) +} + +/** Horizontal bar chart: one `███▌`-style bar per value, scaled to the max, + * each padded to exactly `width` (stable card sizing). Eighth-block tips + * keep adjacent values distinguishable. */ +export function hbars(values: number[], width: number): string[] { + const max = Math.max(...values, 0) || 1 + + return values.map(v => { + const cells = (Math.min(max, Math.max(0, v)) / max) * width + const full = Math.floor(cells) + const rest = Math.round((cells - full) * 8) + + return ('█'.repeat(full) + (rest > 0 ? '▏▎▍▌▋▊▉█'[rest - 1] : '')).padEnd(width) + }) +} diff --git a/ui-tui/src/sdk/apps/ticker.tsx b/ui-tui/src/sdk/apps/ticker.tsx index 6c6aba70867d..ae6da2732129 100644 --- a/ui-tui/src/sdk/apps/ticker.tsx +++ b/ui-tui/src/sdk/apps/ticker.tsx @@ -2,6 +2,7 @@ import { Box, Text } from '@hermes/ink' import { useEffect, useState } from 'react' import { Dialog } from '../../components/overlay.js' +import { sparkline } from '../../lib/charts.js' import type { Theme } from '../../theme.js' import { defineWidgetApp } from '../registry.js' import { isCtrl } from '../types.js' @@ -13,7 +14,6 @@ import { isCtrl } from '../types.js' */ const USAGE = 'usage: /ticker [symbol]' -const BLOCKS = '▁▂▃▄▅▆▇█' const POINTS = 26 const TICK_MS = 250 const PIP = 0.0001 @@ -22,13 +22,6 @@ export interface TickerState { symbol: string } -const spark = (series: number[]): string => { - const min = Math.min(...series) - const span = Math.max(...series) - min || 1 - - return series.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / span) * BLOCKS.length))]).join('') -} - function Chart({ symbol, t }: { symbol: string; t: Theme }) { const [series, setSeries] = useState(() => { const seed = 1.1 + Math.random() * 0.4 @@ -67,7 +60,7 @@ function Chart({ symbol, t }: { symbol: string; t: Theme }) { {Math.abs(delta / PIP).toFixed(1)}p - {spark(series)} + {sparkline(series)} ) } diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx index daee3d482610..676a067c70a3 100644 --- a/ui-tui/src/sdk/apps/weather.tsx +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -1,6 +1,8 @@ import { Box, Text } from '@hermes/ink' +import { ShimmerRows } from '../../components/loaders.js' import { Dialog } from '../../components/overlay.js' +import { mix } from '../../lib/color.js' import type { Theme } from '../../theme.js' import { updateWidget } from '../host.js' import { defineWidgetApp } from '../registry.js' @@ -15,6 +17,14 @@ import { isCtrl } from '../types.js' const USAGE = 'usage: /weather [location] (blank = geolocate by IP)' +// Skeleton mirrors the ready layout: art column + four stat lines. +const LOADING_ROWS: readonly (readonly [number, number])[] = [ + [13, 12], + [13, 16], + [13, 14], + [13, 11] +] + type Phase = { kind: 'error'; message: string } | { kind: 'loading' } | { kind: 'ready'; report: Report } export interface WeatherState { @@ -154,7 +164,13 @@ export const weatherApp = defineWidgetApp({ return ( - {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'loading' && ( + + )} {phase.kind === 'error' && {phase.message}} {phase.kind === 'ready' && } diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 772948e57695..2ca567c53361 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -11,9 +11,11 @@ * See `sdk/apps/` for the reference apps (`/grid-test`, `/dialog-test`). */ +// Theme + chrome primitives +export { Accordion } from '../components/accordion.js' +export { Shimmer, ShimmerRows, shimmerSegments, useShimmerPhase } from '../components/loaders.js' // 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, @@ -26,6 +28,7 @@ export { export { GridAreas, WidgetGrid } from '../components/widgetGrid.js' +export { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js' export { contrastRatio, liftForContrast, mix, relativeLuminance } from '../lib/color.js' // Layout engine export { diff --git a/ui-tui/src/sdk/userWidgets.ts b/ui-tui/src/sdk/userWidgets.ts index f423204e1f88..895f6e61e777 100644 --- a/ui-tui/src/sdk/userWidgets.ts +++ b/ui-tui/src/sdk/userWidgets.ts @@ -7,8 +7,11 @@ import { pathToFileURL } from 'url' import { Box, Text } from '@hermes/ink' import * as React from 'react' +import { Accordion } from '../components/accordion.js' +import { Shimmer, ShimmerRows, useShimmerPhase } from '../components/loaders.js' import { Dialog, Overlay } from '../components/overlay.js' import { GridAreas, WidgetGrid } from '../components/widgetGrid.js' +import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js' import { recordParentLifecycle } from '../lib/parentLog.js' import { openWidget, updateWidget } from './host.js' @@ -31,18 +34,26 @@ import { isCtrl } from './types.js' /** Everything a user widget may touch, passed INTO its register() — user * files have no resolvable import path to the bundle. */ export const widgetSdk = { + Accordion, Box, Dialog, GridAreas, Overlay, React, + Shimmer, + ShimmerRows, Text, WidgetGrid, defineWidgetApp, + gauge, h: React.createElement, + hbars, isCtrl, openWidget, - updateWidget + sparkRows, + sparkline, + updateWidget, + useShimmerPhase } as const export type WidgetSdk = typeof widgetSdk From a7e26716397415c90a46d3aae8d02018364917a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:37:26 -0500 Subject: [PATCH 6/8] =?UTF-8?q?docs(skill):=20tui-widgets=20=E2=80=94=20au?= =?UTF-8?q?to-open=20recipe=20(openWidget=20at=20end=20of=20register)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/productivity/tui-widgets/SKILL.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md index a18c81460a5f..2122ef2210cd 100644 --- a/skills/productivity/tui-widgets/SKILL.md +++ b/skills/productivity/tui-widgets/SKILL.md @@ -40,6 +40,10 @@ widgets. widgets directory is watched); `/widgets-reload` forces a rescan. 3. The widget's id becomes its slash command automatically (`/`), with its `help` in the `/` completion popover. No other registration exists. +4. Auto-open (no command needed): end `register(sdk)` with + `sdk.openWidget(app, app.init(''))` — the widget docks itself the moment + the file loads. Only do this when the user asked for it; note it re-docks + on every `/widgets-reload`. ## Quick Reference From 9627d4f43f756ff1f7705e284bfec87113f58312 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:44:13 -0500 Subject: [PATCH 7/8] feat(ui-tui): ambient zone system + widget crash boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full placement grid so the agent can put a widget where it asks — dock-top/ bottom and corner zones, with corners as reserved rails that take real space instead of floating over content. A per-widget error boundary plus lenient ShimmerRows means generated widget code can't crash the TUI. --- skills/productivity/tui-widgets/SKILL.md | 16 ++- ui-tui/src/__tests__/widgetSdk.test.ts | 75 +++++++++++++ ui-tui/src/components/appLayout.tsx | 12 +- ui-tui/src/sdk/host.tsx | 134 ++++++++++++++++++++--- ui-tui/src/sdk/index.ts | 21 +++- ui-tui/src/sdk/types.ts | 23 ++++ 6 files changed, 258 insertions(+), 23 deletions(-) diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md index 2122ef2210cd..796f12a370e6 100644 --- a/skills/productivity/tui-widgets/SKILL.md +++ b/skills/productivity/tui-widgets/SKILL.md @@ -98,9 +98,19 @@ and render `sparkRows` for dashboard panels, `sparkline` for one-liners. Contract essentials: -- `mode: 'ambient'` — docks above the status bar, captures no input, the - command toggles it; `render` returns a CARD (usually `Dialog`), never - `Overlay`. +- `mode: 'ambient'` — captures no input, the command toggles it; `render` + returns a CARD (usually `Dialog`), never `Overlay`. Placement via `zone` — every zone RESERVES real space (nothing ever + paints over the transcript): + - Docks (chrome rows): `dock-top` (under the top status bar), + `dock-bottom` (default — above the bottom one). + - Rails (side columns beside the transcript; text reflows around them): + `top-left`, `top-right`, `bottom-left`, `bottom-right` — corner names + pick the rail side and its top/bottom anchor. Set `width` on the app + to the card's width (match your Dialog width; default 44) — the rail + reserves exactly that many columns. + Map the user's words to the nearest zone: "top right" → `top-right`, + "above/next to the status bar" → a dock. Rails suit narrow cards + (~30-46 cols); full-width or short-and-wide content belongs in a dock. - `mode: 'modal'` (default) — owns every keypress; `reduce` returns next state, the same reference to swallow a key, or `null` to close; `render` wraps content in `Overlay` for placement. diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index c00d8e5c05a8..b68c0709ced5 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -52,6 +52,29 @@ describe('widget SDK host', () => { expect(getOverlayState().widget).toBeNull() }) + it('a widget that throws in render shows an error chip, not a dead TUI', async () => { + const { defineWidgetApp } = await import('../sdk/registry.js') + const { AmbientDock } = await import('../sdk/host.js') + const { renderToScreen } = await import('../../packages/hermes-ink/src/ink/render-to-screen.js') + const { createElement } = await import('react') + + defineWidgetApp({ + help: 'crash test', + id: 'crash-test', + mode: 'ambient', + init: () => ({}), + reduce: state => state, + render: () => { + throw new Error('boom') + } + }) + + launchWidget('crash-test', 'x') + + // Renders the boundary chip instead of propagating the throw. + expect(() => renderToScreen(createElement(AmbientDock, { placement: 'dock-bottom' }), 60)).not.toThrow() + }) + 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' } }) @@ -69,6 +92,58 @@ describe('widget SDK host', () => { expect($isBlocked.get()).toBe(true) }) + it('ambient zones route by the app contract (docks + floats)', async () => { + const { defineWidgetApp } = await import('../sdk/registry.js') + const { Text } = await import('@hermes/ink') + const { createElement } = await import('react') + + defineWidgetApp({ + help: 'corner test app', + id: 'corner-test', + mode: 'ambient', + zone: 'top-right', + init: () => ({}), + reduce: state => state, + render: () => createElement(Text, null, 'corner') + }) + + launchWidget('corner-test', 'x') + launchWidget('ticker', 'x') + + const zoneOf = (id: string) => getWidgetApp(id)?.zone ?? 'dock-bottom' + + expect(getOverlayState().ambient.map(a => [a.appId, zoneOf(a.appId)])).toEqual([ + ['corner-test', 'top-right'], + ['ticker', 'dock-bottom'] + ]) + }) + + it('rails reserve the widest railed app; docks reserve nothing sideways', async () => { + const { ambientRailWidth } = await import('../sdk/host.js') + const { defineWidgetApp } = await import('../sdk/registry.js') + const { Text } = await import('@hermes/ink') + const { createElement } = await import('react') + + defineWidgetApp({ + help: 'wide rail app', + id: 'rail-wide', + mode: 'ambient', + width: 52, + zone: 'top-right', + init: () => ({}), + reduce: state => state, + render: () => createElement(Text, null, 'wide') + }) + + expect(ambientRailWidth('right')).toBe(0) + launchWidget('corner-test', 'x') // top-right, default width 44 + launchWidget('rail-wide', 'x') + launchWidget('ticker', 'x') // dock-bottom — no rail contribution + + expect(ambientRailWidth('right')).toBe(52) + expect(ambientRailWidth('left')).toBe(0) + }) + it('ambient apps dock together and toggle independently', () => { expect(launchWidget('ticker', 'eurusd')).toBeNull() expect(launchWidget('weather', '')).toBeNull() diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d6a2cefacdb8..d6333eccd4a2 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -22,7 +22,7 @@ import { } from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' import { composerPromptText } from '../lib/prompt.js' -import { ActiveWidgetSlot, AmbientDock } from '../sdk/host.js' +import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -143,14 +143,15 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { const ui = useStore($uiState) const petBox = useStore($petBox) + const railCols = useAmbientRailWidth('left') + useAmbientRailWidth('right') // Keep transcript text clear of the floating pet, responsively: // - wide terminals: reserve a right gutter so lines wrap to the pet's left // (as long as enough width is left for comfortable reading); // - narrow terminals: keep full width and reserve bottom rows instead, so // the newest lines sit above the pet rather than getting cramped. - const useGutter = !!petBox && composer.cols - petBox.width >= MIN_GUTTER_BODY_COLS - const bodyCols = useGutter && petBox ? composer.cols - petBox.width : composer.cols + const useGutter = !!petBox && composer.cols - railCols - petBox.width >= MIN_GUTTER_BODY_COLS + const bodyCols = Math.max(28, (useGutter && petBox ? composer.cols - petBox.width : composer.cols) - railCols) const petBandRows = petBox && !useGutter ? petBox.height : 0 // LiveTodoPanel rides as a child of the latest user-message row so it @@ -362,6 +363,7 @@ const ComposerPane = memo(function ComposerPane({ )} + ⚕ {ui.status}} - + ) @@ -530,6 +532,7 @@ export const AppLayout = memo(function AppLayout({ + {!overlay.agents && !overlay.journey && } {overlay.agents ? ( @@ -543,6 +546,7 @@ export const AppLayout = memo(function AppLayout({ )} + {!overlay.agents && !overlay.journey && } {!overlay.agents && !overlay.journey && ( diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index 197f77e71ee0..cdd44f9700c8 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -1,13 +1,13 @@ -import { useStdout } from '@hermes/ink' -import { Box } from '@hermes/ink' +import { Box, Text, useStdout } from '@hermes/ink' import { useStore } from '@nanostores/react' -import type { ReactNode } from 'react' +import { Component, type ReactNode } from 'react' import { $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiTheme } from '../app/uiStore.js' +import { recordParentLifecycle } from '../lib/parentLog.js' import { getWidgetApp } from './registry.js' -import type { ActiveWidget, WidgetApp, WidgetInput } from './types.js' +import type { ActiveWidget, AmbientZone, WidgetApp, WidgetInput } from './types.js' /** * The widget-app host. Core integrates through exactly four touchpoints: @@ -130,10 +130,53 @@ export function dispatchWidgetInput(input: WidgetInput): boolean { return true } +/** Crash isolation: a widget throwing in render must NEVER take the TUI + * down (user widgets are agent-generated code). The boundary swaps the + * card for a compact error chip and logs; the app stays registered so a + * hot-reloaded fix re-renders on the next state change. */ +class WidgetBoundary extends Component< + { appId: string; children: ReactNode; errorColor: string }, + { message: null | string } +> { + override state: { message: null | string } = { message: null } + + static getDerivedStateFromError(error: unknown) { + return { message: error instanceof Error ? error.message : String(error) } + } + + override componentDidCatch(error: unknown) { + recordParentLifecycle( + `widget /${this.props.appId} crashed in render: ${error instanceof Error ? error.message : String(error)}` + ) + } + + override render() { + if (this.state.message !== null) { + return ( + + ⚠ /{this.props.appId}: {this.state.message} + + ) + } + + return this.props.children + } +} + const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => { const app = getWidgetApp(active.appId) - return app ? app.render({ ...ctx, state: active.state as never }) : null + if (!app) { + return null + } + + const t = ctx.t as { color: { error: string } } + + return ( + + {app.render({ ...ctx, state: active.state as never })} + + ) } /** Render slot for the MODAL app — viewport-level, so it can anchor @@ -150,27 +193,90 @@ export function ActiveWidgetSlot(): ReactNode { return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }) } -/** The ambient dock: in-FLOW (never floats over the transcript), - * right-aligned, sitting directly above the status bar — GUI-style - * "widgets that just sit there" while the composer stays live. */ -export function AmbientDock(): ReactNode { - const overlay = useStore($overlayState) +const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' + +const useAmbientCtx = () => { const t = useStore($uiTheme) const { stdout } = useStdout() - if (!overlay.ambient.length) { + return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } +} + +/** An in-FLOW dock row: reserves real rows in the chrome (never covers + * content), right-aligned cards. `dock-top` renders under the top status + * bar, `dock-bottom` above the bottom one. */ +export function AmbientDock({ placement }: { placement: 'dock-bottom' | 'dock-top' }): ReactNode { + const overlay = useStore($overlayState) + const ctx = useAmbientCtx() + const docked = overlay.ambient.filter(active => zoneOf(active) === placement) + + if (!docked.length) { return null } - const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } - // paddingRight keeps card borders off the terminal's last column — an // exact-edge border char trips pending-wrap and reads as a clipped border. return ( - {overlay.ambient.map(active => ( + {docked.map(active => ( {renderApp(active, ctx)} ))} ) } + +const DEFAULT_RAIL_WIDTH = 44 + +const railSide = (zone: AmbientZone): 'left' | 'right' | null => + zone === 'top-left' || zone === 'bottom-left' ? 'left' : zone === 'top-right' || zone === 'bottom-right' ? 'right' : null + +const railApps = (ambient: ActiveWidget[], side: 'left' | 'right') => + ambient.filter(active => railSide(zoneOf(active)) === side) + +/** Columns a rail RESERVES (0 when empty) — the transcript's width budget + * subtracts this, so widgets genuinely take up space and text reflows + * beside them instead of being painted over. */ +export function ambientRailWidth(side: 'left' | 'right', ambient = $overlayState.get().ambient): number { + const apps = railApps(ambient, side) + + return apps.length ? Math.max(...apps.map(active => getWidgetApp(active.appId)?.width ?? DEFAULT_RAIL_WIDTH)) : 0 +} + +/** Live rail width for layout math (re-renders on dock changes). */ +export function useAmbientRailWidth(side: 'left' | 'right'): number { + const overlay = useStore($overlayState) + + return ambientRailWidth(side, overlay.ambient) +} + +/** A side rail: a RESERVED column beside the transcript holding corner + * widgets — `top-*` zones anchor to its top, `bottom-*` to its bottom. + * Widgets take real space; nothing overlays content. */ +export function AmbientRail({ side }: { side: 'left' | 'right' }): ReactNode { + const overlay = useStore($overlayState) + const ctx = useAmbientCtx() + const apps = railApps(overlay.ambient, side) + + if (!apps.length) { + return null + } + + const top = apps.filter(active => zoneOf(active).startsWith('top')) + const bottom = apps.filter(active => zoneOf(active).startsWith('bottom')) + const width = ambientRailWidth(side, overlay.ambient) + + return ( + + + {top.map(active => ( + {renderApp(active, ctx)} + ))} + + + {bottom.map(active => ( + {renderApp(active, ctx)} + ))} + + + ) +} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 2ca567c53361..6070ce02cdb0 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -46,7 +46,24 @@ export { export type { Theme, ThemeColors } from '../theme.js' // App contract + host -export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js' +export { + ActiveWidgetSlot, + AmbientDock, + AmbientRail, + ambientRailWidth, + closeWidget, + dispatchWidgetInput, + launchWidget, + openWidget, + updateWidget +} from './host.js' export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js' -export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js' +export { + type ActiveWidget, + type AmbientZone, + isCtrl, + type WidgetApp, + type WidgetInput, + type WidgetRenderCtx +} from './types.js' export { loadUserWidgets, type UserWidgetLoadResult, widgetSdk, type WidgetSdk } from './userWidgets.js' diff --git a/ui-tui/src/sdk/types.ts b/ui-tui/src/sdk/types.ts index e19d6f3ce92e..f5ffe90b822a 100644 --- a/ui-tui/src/sdk/types.ts +++ b/ui-tui/src/sdk/types.ts @@ -43,12 +43,35 @@ export interface WidgetApp { * the same id again toggles it closed. */ mode?: 'ambient' | 'modal' + /** Ambient placement — see AmbientZone. Default `dock-bottom`. */ + zone?: AmbientZone + /** Card width in cells (ambient). Floats RESERVE this as a transcript + * rail, so match your Dialog width. Default 44. */ + width?: number init(arg: string): null | S reduce(state: S, input: WidgetInput): null | S render(ctx: WidgetRenderCtx): ReactNode usage?: string } +/** + * Where an ambient widget lives. Two placement families: + * + * DOCKS are in-FLOW chrome rows (they reserve real rows, never cover + * content): `dock-top` under the top status bar, `dock-bottom` above the + * bottom one. Each dock is a right-aligned row of cards. + * + * FLOATS overlay the transcript margins without reserving layout + * (position:absolute against the viewport, GUI-corner style): + * `top-left` | `top-right` | `bottom-left` | `bottom-right`. Floats in the + * same corner stack vertically. Content under a float stays live — floats + * suit sparse corners; prefer docks for anything tall. + * + * Users phrase placement loosely ("top right", "pin it above the status + * bar") — map words to the nearest zone; corners mean floats. + */ +export type AmbientZone = 'bottom-left' | 'bottom-right' | 'dock-bottom' | 'dock-top' | 'top-left' | 'top-right' + /** The host's serializable record of the active app. */ export interface ActiveWidget { appId: string From 2ed61d486c98214d57ea66c896e174fe27dc7314 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:30:16 -0500 Subject: [PATCH 8/8] refactor(ui-tui): host placement router + grid-test width-floor fix host.tsx collapses to one placement router over a shared render context, and the grid-test app drops its width floor too (carrying the #20379 review rule). Final formatting pass folded in. --- ui-tui/src/__tests__/widgetSdk.test.ts | 9 +- ui-tui/src/sdk/apps/dialogTest.tsx | 1 - ui-tui/src/sdk/apps/gridTest.tsx | 5 +- ui-tui/src/sdk/host.tsx | 164 +++++++++++++------------ 4 files changed, 94 insertions(+), 85 deletions(-) diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index b68c0709ced5..ba6022b75e74 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -7,13 +7,18 @@ 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 + ({ + 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().map(app => app.id)).toEqual(expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather'])) + expect(listWidgetApps().map(app => app.id)).toEqual( + expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather']) + ) expect(getWidgetApp('grid-test')).toBe(gridTestApp) }) diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 7f89cae93b74..b8af88e4ecbd 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -64,4 +64,3 @@ export const dialogTestApp = defineWidgetApp({ ) } }) - diff --git a/ui-tui/src/sdk/apps/gridTest.tsx b/ui-tui/src/sdk/apps/gridTest.tsx index 891931bee5ea..955dc20be69f 100644 --- a/ui-tui/src/sdk/apps/gridTest.tsx +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -13,7 +13,8 @@ const USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · 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) +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) @@ -198,7 +199,7 @@ export const gridTestApp = defineWidgetApp({ return ( - + ) diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index cdd44f9700c8..cf2d611f54b5 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -12,21 +12,35 @@ import type { ActiveWidget, AmbientZone, WidgetApp, WidgetInput } from './types. /** * The widget-app host. Core integrates through exactly four touchpoints: * launch (slash commands), dispatch (the input pipeline), the MODAL render - * slot (viewport-level), and the AMBIENT dock (in-flow, above the status - * bar). Everything else — state shape, keybindings, presentation — belongs - * to the app. + * slot (viewport-level), and the AMBIENT surfaces (dock rows + side rails, + * all reserving real space). Everything else — state shape, keybindings, + * presentation — belongs to the app. */ +// ── placement ──────────────────────────────────────────────────────── + const isAmbient = (app: WidgetApp) => app.mode === 'ambient' -const withoutApp = (dock: ActiveWidget[], id: string) => dock.filter(active => active.appId !== id) +const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' -const dockWith = (dock: ActiveWidget[], entry: ActiveWidget) => [...withoutApp(dock, entry.appId), entry] +const withoutApp = (ambient: ActiveWidget[], id: string) => ambient.filter(active => active.appId !== id) + +/** Route a launched app to its slot: ambient apps join the dock array + * (replacing any prior instance), modal apps take the single modal slot. */ +function place(app: WidgetApp, state: unknown): void { + if (isAmbient(app)) { + patchOverlayState({ ambient: [...withoutApp($overlayState.get().ambient, app.id), { appId: app.id, state }] }) + } else { + patchOverlayState({ widget: { appId: app.id, state } }) + } +} + +// ── launch / close / update ────────────────────────────────────────── /** Launch by id. Returns null on success, a printable error/usage line on - * refusal — the caller owns the transcript. Relaunching a DOCKED ambient - * app (with no new argument) toggles it out of the dock — ambient apps - * capture no input, so the command is their only dismissal. */ + * refusal — the caller owns the transcript. Relaunching an active ambient + * app (with no new argument) toggles it away — ambient apps capture no + * input, so the command is their only dismissal. */ export function launchWidget(id: string, arg = ''): null | string { const app = getWidgetApp(id) @@ -35,10 +49,10 @@ export function launchWidget(id: string, arg = ''): null | string { } if (isAmbient(app)) { - const dock = $overlayState.get().ambient + const ambient = $overlayState.get().ambient - if (dock.some(active => active.appId === id) && !arg.trim()) { - patchOverlayState({ ambient: withoutApp(dock, id) }) + if (ambient.some(active => active.appId === id) && !arg.trim()) { + patchOverlayState({ ambient: withoutApp(ambient, id) }) return null } @@ -50,11 +64,7 @@ export function launchWidget(id: string, arg = ''): null | string { return app.usage ?? `usage: /${id}` } - if (isAmbient(app)) { - patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: id, state }) }) - } else { - patchOverlayState({ widget: { appId: id, state } }) - } + place(app, state) return null } @@ -65,40 +75,30 @@ export const closeWidget = () => patchOverlayState({ widget: null }) /** Programmatic, TYPED launch — bypasses string parsing. Apps use this to * stack each other (the host swaps the active modal app). */ -export function openWidget(app: WidgetApp, state: S): void { - if (isAmbient(app as WidgetApp)) { - patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: app.id, state }) }) - } else { - patchOverlayState({ widget: { appId: app.id, state } }) - } -} +export const openWidget = (app: WidgetApp, state: S): void => place(app as WidgetApp, state) /** Async state delivery: patch the app's state ONLY while it is still active * in its slot — a late fetch resolution can never resurrect a closed app or * clobber a different one. This is how data-backed apps land results * outside the input pipeline (see the weather reference app). */ export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { - if (isAmbient(app as WidgetApp)) { - const dock = $overlayState.get().ambient + const overlay = $overlayState.get() - if (!dock.some(active => active.appId === app.id)) { - return + if (isAmbient(app as WidgetApp)) { + if (overlay.ambient.some(active => active.appId === app.id)) { + patchOverlayState({ + ambient: overlay.ambient.map(active => + active.appId === app.id ? { appId: app.id, state: fn(active.state as S) } : active + ) + }) } - patchOverlayState({ - ambient: dock.map(active => (active.appId === app.id ? { appId: app.id, state: fn(active.state as S) } : active)) - }) - return } - const active = $overlayState.get().widget - - if (active?.appId !== app.id) { - return + if (overlay.widget?.appId === app.id) { + patchOverlayState({ widget: { appId: app.id, state: fn(overlay.widget.state as S) } }) } - - patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } }) } /** Feed one keypress to the active MODAL app (ambient apps capture no @@ -130,6 +130,8 @@ export function dispatchWidgetInput(input: WidgetInput): boolean { return true } +// ── render ─────────────────────────────────────────────────────────── + /** Crash isolation: a widget throwing in render must NEVER take the TUI * down (user widgets are agent-generated code). The boundary swaps the * card for a compact error chip and logs; the app stays registered so a @@ -163,43 +165,52 @@ class WidgetBoundary extends Component< } } -const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => { +interface RenderCtx { + cols: number + rows: number + t: never +} + +const useRenderCtx = (): RenderCtx => { + const t = useStore($uiTheme) + const { stdout } = useStdout() + + return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } +} + +const renderApp = (active: ActiveWidget, ctx: RenderCtx) => { const app = getWidgetApp(active.appId) if (!app) { return null } - const t = ctx.t as { color: { error: string } } - return ( - + {app.render({ ...ctx, state: active.state as never })} ) } +const CardStack = ({ apps, ctx }: { apps: ActiveWidget[]; ctx: RenderCtx }) => ( + + {apps.map(active => ( + {renderApp(active, ctx)} + ))} + +) + /** Render slot for the MODAL app — viewport-level, so it 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() + const ctx = useRenderCtx() - if (!overlay.widget) { - return null - } - - return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }) -} - -const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' - -const useAmbientCtx = () => { - const t = useStore($uiTheme) - const { stdout } = useStdout() - - return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } + return overlay.widget ? renderApp(overlay.widget, ctx) : null } /** An in-FLOW dock row: reserves real rows in the chrome (never covers @@ -207,7 +218,7 @@ const useAmbientCtx = () => { * bar, `dock-bottom` above the bottom one. */ export function AmbientDock({ placement }: { placement: 'dock-bottom' | 'dock-top' }): ReactNode { const overlay = useStore($overlayState) - const ctx = useAmbientCtx() + const ctx = useRenderCtx() const docked = overlay.ambient.filter(active => zoneOf(active) === placement) if (!docked.length) { @@ -225,10 +236,12 @@ export function AmbientDock({ placement }: { placement: 'dock-bottom' | 'dock-to ) } +// ── rails ──────────────────────────────────────────────────────────── + const DEFAULT_RAIL_WIDTH = 44 const railSide = (zone: AmbientZone): 'left' | 'right' | null => - zone === 'top-left' || zone === 'bottom-left' ? 'left' : zone === 'top-right' || zone === 'bottom-right' ? 'right' : null + zone.endsWith('-left') ? 'left' : zone.endsWith('-right') ? 'right' : null const railApps = (ambient: ActiveWidget[], side: 'left' | 'right') => ambient.filter(active => railSide(zoneOf(active)) === side) @@ -244,39 +257,30 @@ export function ambientRailWidth(side: 'left' | 'right', ambient = $overlayState /** Live rail width for layout math (re-renders on dock changes). */ export function useAmbientRailWidth(side: 'left' | 'right'): number { - const overlay = useStore($overlayState) - - return ambientRailWidth(side, overlay.ambient) + return ambientRailWidth(side, useStore($overlayState).ambient) } /** A side rail: a RESERVED column beside the transcript holding corner - * widgets — `top-*` zones anchor to its top, `bottom-*` to its bottom. - * Widgets take real space; nothing overlays content. */ + * widgets — `top-*` zones stack from its top, `bottom-*` from its bottom. */ export function AmbientRail({ side }: { side: 'left' | 'right' }): ReactNode { const overlay = useStore($overlayState) - const ctx = useAmbientCtx() + const ctx = useRenderCtx() const apps = railApps(overlay.ambient, side) if (!apps.length) { return null } - const top = apps.filter(active => zoneOf(active).startsWith('top')) - const bottom = apps.filter(active => zoneOf(active).startsWith('bottom')) - const width = ambientRailWidth(side, overlay.ambient) - return ( - - - {top.map(active => ( - {renderApp(active, ctx)} - ))} - - - {bottom.map(active => ( - {renderApp(active, ctx)} - ))} - + + zoneOf(active).startsWith('top'))} ctx={ctx} /> + zoneOf(active).startsWith('bottom'))} ctx={ctx} /> ) }