diff --git a/ui-tui/src/__tests__/weatherApp.test.ts b/ui-tui/src/__tests__/weatherApp.test.ts index 91f6fae17358..35a9818850ca 100644 --- a/ui-tui/src/__tests__/weatherApp.test.ts +++ b/ui-tui/src/__tests__/weatherApp.test.ts @@ -22,7 +22,7 @@ const wttrReply = (weatherCode: string) => ({ nearest_area: [{ areaName: [{ value: 'Austin' }], country: [{ value: 'USA' }] }] }) -const activeState = () => getOverlayState().widget?.state as undefined | WeatherState +const activeState = () => getOverlayState().ambient.find(a => a.appId === 'weather')?.state as undefined | WeatherState beforeEach(() => resetOverlayState()) afterEach(() => vi.unstubAllGlobals()) @@ -55,13 +55,13 @@ describe('weather reference app (async contract)', () => { launchWidget('weather', '') expect(activeState()?.phase.kind).toBe('loading') - // Close while in flight, then resolve. - expect(weatherApp.reduce(activeState()!, key({ escape: true }))).toBeNull() - resetOverlayState() + // Toggle closed while in flight (ambient dismissal), then resolve. + expect(launchWidget('weather', '')).toBeNull() + expect(getOverlayState().ambient).toEqual([]) resolve({ json: async () => wttrReply('113'), ok: true }) await new Promise(r => setTimeout(r, 0)) - expect(getOverlayState().widget).toBeNull() + expect(getOverlayState().ambient).toEqual([]) }) it('fetch failure lands as an error phase', async () => { diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index c18773a83683..c00d8e5c05a8 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -13,7 +13,7 @@ beforeEach(() => resetOverlayState()) describe('widget SDK host', () => { it('registers the reference apps', () => { - expect(listWidgetApps()).toEqual(expect.arrayContaining(['dialog-test', 'grid-test'])) + expect(listWidgetApps().map(app => app.id)).toEqual(expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather'])) expect(getWidgetApp('grid-test')).toBe(gridTestApp) }) @@ -59,11 +59,23 @@ describe('widget SDK host', () => { expect(getOverlayState().widget).toBeNull() }) - it('a widget in the slot blocks the composer', async () => { + it('a MODAL widget blocks the composer; ambient never does', async () => { const { $isBlocked } = await import('../app/overlayStore.js') + expect($isBlocked.get()).toBe(false) + launchWidget('ticker', '') expect($isBlocked.get()).toBe(false) launchWidget('dialog-test', 'center') expect($isBlocked.get()).toBe(true) }) + + it('ambient apps dock together and toggle independently', () => { + expect(launchWidget('ticker', 'eurusd')).toBeNull() + expect(launchWidget('weather', '')).toBeNull() + expect(getOverlayState().ambient.map(a => a.appId)).toEqual(['ticker', 'weather']) + + // Relaunch with no arg toggles just that app out of the dock. + expect(launchWidget('ticker', '')).toBeNull() + expect(getOverlayState().ambient.map(a => a.appId)).toEqual(['weather']) + }) }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 414542046dd8..44ff7fa2a2c1 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -284,6 +284,9 @@ export interface OverlayState { billing: BillingOverlayState | null clarify: ClarifyReq | null confirm: ConfirmReq | null + /** Ambient widget apps — glanceable dock, non-blocking (never in $isBlocked). */ + ambient: ActiveWidget[] + /** Modal widget app — owns input, blocks the composer. */ widget: ActiveWidget | null journey: boolean modelPicker: boolean | { refresh?: boolean } diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index e2148ddca5ce..4e26099a0670 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -9,6 +9,7 @@ const buildOverlayState = (): OverlayState => ({ billing: null, clarify: null, confirm: null, + ambient: [], widget: null, journey: false, modelPicker: false, @@ -85,6 +86,7 @@ export const resetFlowOverlays = () => ...buildOverlayState(), agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, + ambient: $overlayState.get().ambient, widget: $overlayState.get().widget, journey: $overlayState.get().journey, modelPicker: $overlayState.get().modelPicker, diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index ea98b55e546c..7fa0b3797c71 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -5,28 +5,28 @@ import { terminalBackgroundHex } from '@hermes/ink' import { formatBytes, performHeapDump } from '../../../lib/memory.js' import { launchWidget } from '../../../sdk/host.js' +import { listWidgetApps } from '../../../sdk/registry.js' import { detectLightMode } from '../../../theme.js' import { getUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -/** Slash command → SDK widget-app launch. The app owns parsing (init), - * keybindings (reduce), and placement (render); refusals print usage. */ -const widgetCommand = (name: string, help: string): SlashCommand => ({ - help, - name, +/** The registry IS the catalog: every registered widget app becomes a slash + * command carrying the app's own help/usage — nothing hardcoded per app. + * The app owns parsing (init), keybindings (reduce), placement (render). */ +export const widgetAppCommands: SlashCommand[] = listWidgetApps().map(app => ({ + help: app.help, + name: app.id, run: (arg, ctx) => { - const err = launchWidget(name, arg) + const err = launchWidget(app.id, arg) if (err) { ctx.transcript.sys(err) } } -}) +})) export const debugCommands: SlashCommand[] = [ - widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'), - widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'), - widgetCommand('weather', 'current conditions with themed ASCII art (wttr.in)'), + ...widgetAppCommands, { help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)', diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3efa2c203c15..d6a2cefacdb8 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -22,7 +22,7 @@ import { } from '../lib/inputMetrics.js' import { PerfPane } from '../lib/perfPane.js' import { composerPromptText } from '../lib/prompt.js' -import { ActiveWidgetSlot } from '../sdk/host.js' +import { ActiveWidgetSlot, AmbientDock } from '../sdk/host.js' import { AgentsOverlay } from './agentsOverlay.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' @@ -442,6 +442,7 @@ const ComposerPane = memo(function ComposerPane({ {!composer.empty && !ui.sid && ⚕ {ui.status}} + ) diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index b3e31696ab6c..5e0dc33c540b 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -5,6 +5,24 @@ import { looksLikeSlashCommand } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { CompletionResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' +import { listWidgetApps } from '../sdk/registry.js' + +/** Client-side widget apps live in the TUI's registry, not the gateway — so + * `/` completions merge their title/metadata here. Registry-driven: a new + * app surfaces automatically, no hardcoded lists on either side. */ +export function mergeWidgetAppItems(input: string, items: CompletionItem[]): CompletionItem[] { + // Only complete the command NAME position (no args typed yet). + if (input.includes(' ')) { + return items + } + + const local = listWidgetApps() + .filter(app => `/${app.id}`.startsWith(input.toLowerCase())) + .filter(app => !items.some(item => item.text === `/${app.id}`)) + .map(app => ({ display: `/${app.id}`, meta: app.help, text: `/${app.id}` })) + + return [...items, ...local] +} const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ @@ -85,7 +103,10 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const r = asRpcResult(raw) - setCompletions(r?.items ?? []) + const items = + request.method === 'complete.slash' ? mergeWidgetAppItems(input, r?.items ?? []) : (r?.items ?? []) + + setCompletions(items) setCompIdx(0) setCompReplace(request.method === 'complete.slash' ? (r?.replace_from ?? 1) : request.replaceFrom) }) diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 896a62c2dce5..7f89cae93b74 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -35,6 +35,7 @@ const defaultBody = (zone: OverlayZone) => export const dialogTestApp = defineWidgetApp({ id: 'dialog-test', + help: 'open a sample dialog overlay with a faked backdrop', usage: USAGE, init(arg) { diff --git a/ui-tui/src/sdk/apps/gridTest.tsx b/ui-tui/src/sdk/apps/gridTest.tsx index c51ef937bde9..891931bee5ea 100644 --- a/ui-tui/src/sdk/apps/gridTest.tsx +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -89,6 +89,7 @@ function reduceStreams(grid: GridTestState, { ch, key }: WidgetInput): GridTestS export const gridTestApp = defineWidgetApp({ id: 'grid-test', + help: 'open an interactive widget-grid demo overlay', usage: USAGE, init(arg) { diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index 3a39a104ac78..34037745f223 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -3,4 +3,5 @@ export { dialogTestApp } from './dialogTest.js' export { gridTestApp } from './gridTest.js' export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js' +export { tickerApp, type TickerState } from './ticker.js' export { weatherApp, type WeatherState } from './weather.js' diff --git a/ui-tui/src/sdk/apps/ticker.tsx b/ui-tui/src/sdk/apps/ticker.tsx new file mode 100644 index 000000000000..6c6aba70867d --- /dev/null +++ b/ui-tui/src/sdk/apps/ticker.tsx @@ -0,0 +1,99 @@ +import { Box, Text } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import { Dialog } from '../../components/overlay.js' +import type { Theme } from '../../theme.js' +import { defineWidgetApp } from '../registry.js' +import { isCtrl } from '../types.js' + +/** + * Ticker — the animated ambient reference app: a fake 1-pip chart that + * random-walks a price and draws a live block sparkline, streams-demo style + * (the component owns its animation; app state is just the symbol). + */ + +const USAGE = 'usage: /ticker [symbol]' +const BLOCKS = '▁▂▃▄▅▆▇█' +const POINTS = 26 +const TICK_MS = 250 +const PIP = 0.0001 + +export interface TickerState { + symbol: string +} + +const spark = (series: number[]): string => { + const min = Math.min(...series) + const span = Math.max(...series) - min || 1 + + return series.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / span) * BLOCKS.length))]).join('') +} + +function Chart({ symbol, t }: { symbol: string; t: Theme }) { + const [series, setSeries] = useState(() => { + const seed = 1.1 + Math.random() * 0.4 + const out = [seed] + + while (out.length < POINTS) { + out.push(out.at(-1)! + (Math.random() - 0.5) * 4 * PIP) + } + + return out + }) + + useEffect(() => { + const id = setInterval( + () => setSeries(prev => [...prev.slice(1), prev.at(-1)! + (Math.random() - 0.5) * 4 * PIP]), + TICK_MS + ) + + return () => clearInterval(id) + }, []) + + const price = series.at(-1)! + const delta = price - series.at(-2)! + const up = delta >= 0 + const dir = up ? t.color.ok : t.color.error + + return ( + + + + {symbol} + + {price.toFixed(4)} + + {up ? '▲' : '▼'} + {Math.abs(delta / PIP).toFixed(1)}p + + + {spark(series)} + + ) +} + +export const tickerApp = defineWidgetApp({ + id: 'ticker', + help: 'fake 1-pip chart with a live sparkline', + mode: 'ambient', + usage: USAGE, + + init(arg) { + const symbol = (arg.trim().split(/\s+/)[0] || 'HRMS').toUpperCase().slice(0, 8) + + return { symbol } + }, + + // Never receives input while ambient; contract-complete for modal reuse. + reduce(state, { ch, key }) { + return key.escape || ch === 'q' || isCtrl(key, ch, 'c') ? null : state + }, + + render({ state, t }) { + return ( + + + + ) + } +}) diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx index 8f01a259dce5..daee3d482610 100644 --- a/ui-tui/src/sdk/apps/weather.tsx +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -1,6 +1,6 @@ import { Box, Text } from '@hermes/ink' -import { Dialog, Overlay } from '../../components/overlay.js' +import { Dialog } from '../../components/overlay.js' import type { Theme } from '../../theme.js' import { updateWidget } from '../host.js' import { defineWidgetApp } from '../registry.js' @@ -120,6 +120,8 @@ function load(location: string): void { export const weatherApp = defineWidgetApp({ id: 'weather', + help: 'current conditions with themed ASCII art (wttr.in)', + mode: 'ambient', usage: USAGE, init(arg) { @@ -144,18 +146,18 @@ export const weatherApp = defineWidgetApp({ return state }, + // Ambient: renders IN the dock (host owns placement) — a compact card + // that sits above the status bar while the composer stays live. render({ cols, state, t }) { const { phase } = state const title = phase.kind === 'ready' ? phase.report.area : 'Weather' return ( - - - {phase.kind === 'loading' && fetching wttr.in…} - {phase.kind === 'error' && {phase.message}} - {phase.kind === 'ready' && } - - + + {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'error' && {phase.message}} + {phase.kind === 'ready' && } + ) } }) diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index c851e58e18c7..72713f23b2bd 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -1,4 +1,5 @@ import { useStdout } from '@hermes/ink' +import { Box } from '@hermes/ink' import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' @@ -6,17 +7,26 @@ import { $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiTheme } from '../app/uiStore.js' import { getWidgetApp } from './registry.js' -import type { WidgetApp, WidgetInput } from './types.js' +import type { ActiveWidget, WidgetApp, WidgetInput } from './types.js' /** - * The widget-app host. Core integrates through exactly three touchpoints: - * launch (slash commands), dispatch (the input pipeline), and the render - * slot (appLayout). Everything else — state shape, keybindings, placement — - * belongs to the app. + * The widget-app host. Core integrates through exactly four touchpoints: + * launch (slash commands), dispatch (the input pipeline), the MODAL render + * slot (viewport-level), and the AMBIENT dock (in-flow, above the status + * bar). Everything else — state shape, keybindings, presentation — belongs + * to the app. */ +const isAmbient = (app: WidgetApp) => app.mode === 'ambient' + +const withoutApp = (dock: ActiveWidget[], id: string) => dock.filter(active => active.appId !== id) + +const dockWith = (dock: ActiveWidget[], entry: ActiveWidget) => [...withoutApp(dock, entry.appId), entry] + /** Launch by id. Returns null on success, a printable error/usage line on - * refusal — the caller owns the transcript. */ + * refusal — the caller owns the transcript. Relaunching a DOCKED ambient + * app (with no new argument) toggles it out of the dock — ambient apps + * capture no input, so the command is their only dismissal. */ export function launchWidget(id: string, arg = ''): null | string { const app = getWidgetApp(id) @@ -24,30 +34,64 @@ export function launchWidget(id: string, arg = ''): null | string { return `unknown widget app: ${id}` } + if (isAmbient(app)) { + const dock = $overlayState.get().ambient + + if (dock.some(active => active.appId === id) && !arg.trim()) { + patchOverlayState({ ambient: withoutApp(dock, id) }) + + return null + } + } + const state = app.init(arg) if (state === null) { return app.usage ?? `usage: /${id}` } - patchOverlayState({ widget: { appId: id, state } }) + if (isAmbient(app)) { + patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: id, state }) }) + } else { + patchOverlayState({ widget: { appId: id, state } }) + } return null } +/** Close the MODAL app. Ambient apps dismiss via their launch toggle, so a + * modal's Esc can't collaterally clear the dock. */ export const closeWidget = () => patchOverlayState({ widget: null }) /** Programmatic, TYPED launch — bypasses string parsing. Apps use this to - * stack each other (the host swaps the active app). */ + * stack each other (the host swaps the active modal app). */ export function openWidget(app: WidgetApp, state: S): void { - patchOverlayState({ widget: { appId: app.id, state } }) + if (isAmbient(app as WidgetApp)) { + patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: app.id, state }) }) + } else { + patchOverlayState({ widget: { appId: app.id, state } }) + } } -/** Async state delivery: patch the app's state ONLY while it is still the - * active widget — a late fetch resolution can never resurrect a closed app - * or clobber a different one. This is how data-backed apps land results +/** Async state delivery: patch the app's state ONLY while it is still active + * in its slot — a late fetch resolution can never resurrect a closed app or + * clobber a different one. This is how data-backed apps land results * outside the input pipeline (see the weather reference app). */ export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { + if (isAmbient(app as WidgetApp)) { + const dock = $overlayState.get().ambient + + if (!dock.some(active => active.appId === app.id)) { + return + } + + patchOverlayState({ + ambient: dock.map(active => (active.appId === app.id ? { appId: app.id, state: fn(active.state as S) } : active)) + }) + + return + } + const active = $overlayState.get().widget if (active?.appId !== app.id) { @@ -57,8 +101,9 @@ export function updateWidget(app: WidgetApp, fn: (state: S) => S): void { patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } }) } -/** Feed one keypress to the active app. Returns true when a widget is active - * (apps swallow every key while open — the overlay is modal). */ +/** Feed one keypress to the active MODAL app (ambient apps capture no + * input). Returns true when a modal app is active — apps swallow every key + * while open. */ export function dispatchWidgetInput(input: WidgetInput): boolean { const active = $overlayState.get().widget @@ -85,7 +130,13 @@ export function dispatchWidgetInput(input: WidgetInput): boolean { return true } -/** Render slot for the active app — viewport-level, so apps can anchor +const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => { + const app = getWidgetApp(active.appId) + + return app ? app.render({ ...ctx, state: active.state as never }) : null +} + +/** Render slot for the MODAL app — viewport-level, so it can anchor * `Overlay` zones and backdrops against the full terminal. */ export function ActiveWidgetSlot(): ReactNode { const overlay = useStore($overlayState) @@ -96,16 +147,28 @@ export function ActiveWidgetSlot(): ReactNode { return null } - const app = getWidgetApp(overlay.widget.appId) + return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }) +} - if (!app) { +/** The ambient dock: in-FLOW (never floats over the transcript), + * right-aligned, sitting directly above the status bar — GUI-style + * "widgets that just sit there" while the composer stays live. */ +export function AmbientDock(): ReactNode { + const overlay = useStore($overlayState) + const t = useStore($uiTheme) + const { stdout } = useStdout() + + if (!overlay.ambient.length) { return null } - return app.render({ - cols: stdout?.columns ?? 80, - rows: stdout?.rows ?? 24, - state: overlay.widget.state as never, - t - }) + const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } + + return ( + + {overlay.ambient.map(active => ( + {renderApp(active, ctx)} + ))} + + ) } diff --git a/ui-tui/src/sdk/registry.ts b/ui-tui/src/sdk/registry.ts index f3c56129fa01..2a1264987316 100644 --- a/ui-tui/src/sdk/registry.ts +++ b/ui-tui/src/sdk/registry.ts @@ -12,4 +12,6 @@ export function defineWidgetApp(app: WidgetApp): WidgetApp { export const getWidgetApp = (id: string): undefined | WidgetApp => apps.get(id) -export const listWidgetApps = (): string[] => [...apps.keys()].sort() +/** All registered apps, id-sorted — the registry IS the catalog: slash + * commands and `/` completions derive from it, nothing is hardcoded. */ +export const listWidgetApps = (): WidgetApp[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id)) diff --git a/ui-tui/src/sdk/types.ts b/ui-tui/src/sdk/types.ts index 264c805017d6..e19d6f3ce92e 100644 --- a/ui-tui/src/sdk/types.ts +++ b/ui-tui/src/sdk/types.ts @@ -35,6 +35,14 @@ export interface WidgetRenderCtx { */ export interface WidgetApp { id: string + /** One-line description — surfaces in `/` completions and command help. */ + help: string + /** + * `modal` (default): owns every keypress, blocks the composer. + * `ambient`: glanceable panel — no input capture, no blocking; launching + * the same id again toggles it closed. + */ + mode?: 'ambient' | 'modal' init(arg: string): null | S reduce(state: S, input: WidgetInput): null | S render(ctx: WidgetRenderCtx): ReactNode