feat(ui-tui): ambient widget mode — registry-driven slash catalog + in-flow dock

Widgets can render as ambient (glanceable, non-blocking) instead of modal,
docked in the normal layout flow above/below the status bar rather than taking
over the screen. The slash catalog is generated from the widget registry so new
apps surface automatically, and /ticker lands as the first live-animation
ambient demo.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 21:23:06 -05:00
parent fc3be32a9a
commit 76b58d6127
15 changed files with 267 additions and 51 deletions

View file

@ -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 () => {

View file

@ -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'])
})
})

View file

@ -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 }

View file

@ -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,

View file

@ -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)',

View file

@ -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 && <Text color={ui.theme.color.muted}> {ui.status}</Text>}
<AmbientDock />
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
)

View file

@ -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<CompletionResponse>(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)
})

View file

@ -35,6 +35,7 @@ const defaultBody = (zone: OverlayZone) =>
export const dialogTestApp = defineWidgetApp<DialogTestState>({
id: 'dialog-test',
help: 'open a sample dialog overlay with a faked backdrop',
usage: USAGE,
init(arg) {

View file

@ -89,6 +89,7 @@ function reduceStreams(grid: GridTestState, { ch, key }: WidgetInput): GridTestS
export const gridTestApp = defineWidgetApp<GridTestState>({
id: 'grid-test',
help: 'open an interactive widget-grid demo overlay',
usage: USAGE,
init(arg) {

View file

@ -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'

View file

@ -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<number[]>(() => {
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 (
<Box flexDirection="column">
<Box columnGap={1} flexDirection="row">
<Text bold color={t.color.label}>
{symbol}
</Text>
<Text color={t.color.text}>{price.toFixed(4)}</Text>
<Text color={dir}>
{up ? '▲' : '▼'}
{Math.abs(delta / PIP).toFixed(1)}p
</Text>
</Box>
<Text color={dir}>{spark(series)}</Text>
</Box>
)
}
export const tickerApp = defineWidgetApp<TickerState>({
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 (
<Dialog width={Math.max(32, POINTS + 6)}>
<Chart symbol={state.symbol} t={t} />
</Dialog>
)
}
})

View file

@ -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<WeatherState>({
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<WeatherState>({
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 (
<Overlay backdrop zone="center">
<Dialog hint="r refresh · Esc/q close" title={title} width={Math.min(46, cols - 8)}>
{phase.kind === 'loading' && <Text color={t.color.muted}>fetching wttr.in</Text>}
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
</Dialog>
</Overlay>
<Dialog title={title} width={Math.min(42, cols - 4)}>
{phase.kind === 'loading' && <Text color={t.color.muted}>fetching wttr.in</Text>}
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
</Dialog>
)
}
})

View file

@ -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<never>) => 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<S>(app: WidgetApp<S>, state: S): void {
patchOverlayState({ widget: { appId: app.id, state } })
if (isAmbient(app as WidgetApp<never>)) {
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<S>(app: WidgetApp<S>, fn: (state: S) => S): void {
if (isAmbient(app as WidgetApp<never>)) {
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<S>(app: WidgetApp<S>, 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 (
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" width="100%">
{overlay.ambient.map(active => (
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
))}
</Box>
)
}

View file

@ -12,4 +12,6 @@ export function defineWidgetApp<S>(app: WidgetApp<S>): WidgetApp<S> {
export const getWidgetApp = (id: string): undefined | WidgetApp<never> => 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<never>[] => [...apps.values()].sort((a, b) => a.id.localeCompare(b.id))

View file

@ -35,6 +35,14 @@ export interface WidgetRenderCtx<S> {
*/
export interface WidgetApp<S = unknown> {
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<S>): ReactNode