diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md new file mode 100644 index 000000000000..796f12a370e6 --- /dev/null +++ b/skills/productivity/tui-widgets/SKILL.md @@ -0,0 +1,149 @@ +--- +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. +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 + +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`, 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: + +- `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. +- 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__/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/__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__/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/__tests__/weatherApp.test.ts b/ui-tui/src/__tests__/weatherApp.test.ts new file mode 100644 index 000000000000..35a9818850ca --- /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().ambient.find(a => a.appId === 'weather')?.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') + + // 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().ambient).toEqual([]) + }) + + 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/__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..ba6022b75e74 --- /dev/null +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -0,0 +1,161 @@ +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().map(app => app.id)).toEqual( + expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather']) + ) + 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('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' } }) + closeWidget() + expect(getOverlayState().widget).toBeNull() + }) + + 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 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() + 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/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/interfaces.ts b/ui-tui/src/app/interfaces.ts index f9959eba00ce..44ff7fa2a2c1 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,10 @@ export interface OverlayState { billing: BillingOverlayState | null clarify: ClarifyReq | null confirm: ConfirmReq | null - dialog: DialogState | null - gridTest: GridTestState | 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 } pager: null | PagerState @@ -297,41 +300,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..4e26099a0670 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -9,8 +9,8 @@ const buildOverlayState = (): OverlayState => ({ billing: null, clarify: null, confirm: null, - dialog: null, - gridTest: null, + ambient: [], + widget: null, journey: false, modelPicker: false, pager: null, @@ -33,8 +33,6 @@ export const $isBlocked = computed( billing, clarify, confirm, - dialog, - gridTest, journey, modelPicker, pager, @@ -44,7 +42,8 @@ export const $isBlocked = computed( sessions, skillsHub, subscription, - sudo + sudo, + widget }) => Boolean( agents || @@ -52,8 +51,6 @@ export const $isBlocked = computed( billing || clarify || confirm || - dialog || - gridTest || journey || modelPicker || pager || @@ -63,7 +60,8 @@ export const $isBlocked = computed( sessions || skillsHub || subscription || - sudo + sudo || + widget ) ) @@ -88,8 +86,8 @@ export const resetFlowOverlays = () => ...buildOverlayState(), agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, - dialog: $overlayState.get().dialog, - gridTest: $overlayState.get().gridTest, + ambient: $overlayState.get().ambient, + 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..e93208f1b8e5 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -1,113 +1,45 @@ +// 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 { listWidgetApps } from '../../../sdk/registry.js' +import { loadUserWidgets } from '../../../sdk/userWidgets.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 +/** 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(app.id, 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 - } - }) - } - }, + ...widgetAppCommands, { - 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'] + 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}`) + ] - 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 - } + ctx.transcript.sys(`widgets — ${parts.join(' · ')}`) }) } }, 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/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/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/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 8befa68e54fa..d6333eccd4a2 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, AmbientDock, AmbientRail, useAmbientRailWidth } 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' @@ -140,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 @@ -359,6 +363,7 @@ const ComposerPane = memo(function ComposerPane({ )} + ⚕ {ui.status}} + ) @@ -526,6 +532,7 @@ export const AppLayout = memo(function AppLayout({ + {!overlay.agents && !overlay.journey && } {overlay.agents ? ( @@ -539,6 +546,7 @@ export const AppLayout = memo(function AppLayout({ )} + {!overlay.agents && !overlay.journey && } {!overlay.agents && !overlay.journey && ( @@ -568,19 +576,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/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 ce07acb48a18..d94c85b957b2 100644 --- a/ui-tui/src/components/gridStreamsDemo.tsx +++ b/ui-tui/src/components/gridStreamsDemo.tsx @@ -1,8 +1,9 @@ import { Box, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useRef, useState } from 'react' -import type { GridTestState } from '../app/interfaces.js' +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' import { GridAreas, type GridAreaWidget } from './widgetGrid.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/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/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/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/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx new file mode 100644 index 000000000000..b8af88e4ecbd --- /dev/null +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -0,0 +1,66 @@ +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', + help: 'open a sample dialog overlay with a faked backdrop', + 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 }) { + 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..955dc20be69f --- /dev/null +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -0,0 +1,207 @@ +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', + help: 'open an interactive widget-grid demo overlay', + 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..35bd0587f649 --- /dev/null +++ b/ui-tui/src/sdk/apps/index.ts @@ -0,0 +1,13 @@ +/** Reference apps. Importing this module registers them (defineWidgetApp + * 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' +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..ae6da2732129 --- /dev/null +++ b/ui-tui/src/sdk/apps/ticker.tsx @@ -0,0 +1,92 @@ +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' + +/** + * 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 POINTS = 26 +const TICK_MS = 250 +const PIP = 0.0001 + +export interface TickerState { + symbol: string +} + +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 + + + {sparkline(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 new file mode 100644 index 000000000000..676a067c70a3 --- /dev/null +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -0,0 +1,203 @@ +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' +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)' + +// 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 { + 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', + help: 'current conditions with themed ASCII art (wttr.in)', + mode: 'ambient', + 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 + }, + + // 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' && ( + + )} + {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 new file mode 100644 index 000000000000..cf2d611f54b5 --- /dev/null +++ b/ui-tui/src/sdk/host.tsx @@ -0,0 +1,286 @@ +import { Box, Text, useStdout } from '@hermes/ink' +import { useStore } from '@nanostores/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, AmbientZone, WidgetApp, WidgetInput } from './types.js' + +/** + * 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 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 zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' + +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 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) + + if (!app) { + return `unknown widget app: ${id}` + } + + if (isAmbient(app)) { + const ambient = $overlayState.get().ambient + + if (ambient.some(active => active.appId === id) && !arg.trim()) { + patchOverlayState({ ambient: withoutApp(ambient, id) }) + + return null + } + } + + const state = app.init(arg) + + if (state === null) { + return app.usage ?? `usage: /${id}` + } + + place(app, 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 modal app). */ +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 { + const overlay = $overlayState.get() + + 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 + ) + }) + } + + return + } + + if (overlay.widget?.appId === app.id) { + patchOverlayState({ widget: { appId: app.id, state: fn(overlay.widget.state as S) } }) + } +} + +/** 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 + + 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 ─────────────────────────────────────────────────────────── + +/** 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 + } +} + +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 + } + + 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 ctx = useRenderCtx() + + return overlay.widget ? renderApp(overlay.widget, ctx) : null +} + +/** 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 = useRenderCtx() + const docked = overlay.ambient.filter(active => zoneOf(active) === placement) + + if (!docked.length) { + return null + } + + // 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 ( + + {docked.map(active => ( + {renderApp(active, ctx)} + ))} + + ) +} + +// ── rails ──────────────────────────────────────────────────────────── + +const DEFAULT_RAIL_WIDTH = 44 + +const railSide = (zone: AmbientZone): 'left' | '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) + +/** 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 { + return ambientRailWidth(side, useStore($overlayState).ambient) +} + +/** A side rail: a RESERVED column beside the transcript holding corner + * 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 = useRenderCtx() + const apps = railApps(overlay.ambient, side) + + if (!apps.length) { + return null + } + + return ( + + zoneOf(active).startsWith('top'))} ctx={ctx} /> + zoneOf(active).startsWith('bottom'))} ctx={ctx} /> + + ) +} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts new file mode 100644 index 000000000000..6070ce02cdb0 --- /dev/null +++ b/ui-tui/src/sdk/index.ts @@ -0,0 +1,69 @@ +/** + * 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`). + */ + +// 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' +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 { gauge, hbars, sparkline, sparkRows } from '../lib/charts.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, + AmbientDock, + AmbientRail, + ambientRailWidth, + closeWidget, + dispatchWidgetInput, + launchWidget, + openWidget, + updateWidget +} from './host.js' +export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.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/registry.ts b/ui-tui/src/sdk/registry.ts new file mode 100644 index 000000000000..3a94af11357a --- /dev/null +++ b/ui-tui/src/sdk/registry.ts @@ -0,0 +1,20 @@ +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) + +/** 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/types.ts b/ui-tui/src/sdk/types.ts new file mode 100644 index 000000000000..f5ffe90b822a --- /dev/null +++ b/ui-tui/src/sdk/types.ts @@ -0,0 +1,83 @@ +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 + /** 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' + /** 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 + 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 diff --git a/ui-tui/src/sdk/userWidgets.ts b/ui-tui/src/sdk/userWidgets.ts new file mode 100644 index 000000000000..895f6e61e777 --- /dev/null +++ b/ui-tui/src/sdk/userWidgets.ts @@ -0,0 +1,213 @@ +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 { 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' +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 = { + Accordion, + Box, + Dialog, + GridAreas, + Overlay, + React, + Shimmer, + ShimmerRows, + Text, + WidgetGrid, + defineWidgetApp, + gauge, + h: React.createElement, + hbars, + isCtrl, + openWidget, + sparkRows, + sparkline, + updateWidget, + useShimmerPhase +} 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?.() + } + } +}