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 (
-
-
-
+
)
}
})
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