feat(ui-tui): ambient zone system + widget crash boundary

A full placement grid so the agent can put a widget where it asks — dock-top/
bottom and corner zones, with corners as reserved rails that take real space
instead of floating over content. A per-widget error boundary plus lenient
ShimmerRows means generated widget code can't crash the TUI.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 23:44:13 -05:00
parent a7e2671639
commit 9627d4f43f
6 changed files with 258 additions and 23 deletions

View file

@ -98,9 +98,19 @@ and render `sparkRows` for dashboard panels, `sparkline` for one-liners.
Contract essentials:
- `mode: 'ambient'` — docks above the status bar, captures no input, the
command toggles it; `render` returns a CARD (usually `Dialog`), never
`Overlay`.
- `mode: 'ambient'` — captures no input, the command toggles it; `render`
returns a CARD (usually `Dialog`), never `Overlay`. Placement via `zone` — every zone RESERVES real space (nothing ever
paints over the transcript):
- Docks (chrome rows): `dock-top` (under the top status bar),
`dock-bottom` (default — above the bottom one).
- Rails (side columns beside the transcript; text reflows around them):
`top-left`, `top-right`, `bottom-left`, `bottom-right` — corner names
pick the rail side and its top/bottom anchor. Set `width` on the app
to the card's width (match your Dialog width; default 44) — the rail
reserves exactly that many columns.
Map the user's words to the nearest zone: "top right" → `top-right`,
"above/next to the status bar" → a dock. Rails suit narrow cards
(~30-46 cols); full-width or short-and-wide content belongs in a dock.
- `mode: 'modal'` (default) — owns every keypress; `reduce` returns next
state, the same reference to swallow a key, or `null` to close; `render`
wraps content in `Overlay` for placement.

View file

@ -52,6 +52,29 @@ describe('widget SDK host', () => {
expect(getOverlayState().widget).toBeNull()
})
it('a widget that throws in render shows an error chip, not a dead TUI', async () => {
const { defineWidgetApp } = await import('../sdk/registry.js')
const { AmbientDock } = await import('../sdk/host.js')
const { renderToScreen } = await import('../../packages/hermes-ink/src/ink/render-to-screen.js')
const { createElement } = await import('react')
defineWidgetApp({
help: 'crash test',
id: 'crash-test',
mode: 'ambient',
init: () => ({}),
reduce: state => state,
render: () => {
throw new Error('boom')
}
})
launchWidget('crash-test', 'x')
// Renders the boundary chip instead of propagating the throw.
expect(() => renderToScreen(createElement(AmbientDock, { placement: 'dock-bottom' }), 60)).not.toThrow()
})
it('openWidget is a typed direct launch', () => {
openWidget(dialogTestApp, { body: 'hi', zone: 'top-right' })
expect(getOverlayState().widget).toMatchObject({ appId: 'dialog-test', state: { zone: 'top-right' } })
@ -69,6 +92,58 @@ describe('widget SDK host', () => {
expect($isBlocked.get()).toBe(true)
})
it('ambient zones route by the app contract (docks + floats)', async () => {
const { defineWidgetApp } = await import('../sdk/registry.js')
const { Text } = await import('@hermes/ink')
const { createElement } = await import('react')
defineWidgetApp({
help: 'corner test app',
id: 'corner-test',
mode: 'ambient',
zone: 'top-right',
init: () => ({}),
reduce: state => state,
render: () => createElement(Text, null, 'corner')
})
launchWidget('corner-test', 'x')
launchWidget('ticker', 'x')
const zoneOf = (id: string) => getWidgetApp(id)?.zone ?? 'dock-bottom'
expect(getOverlayState().ambient.map(a => [a.appId, zoneOf(a.appId)])).toEqual([
['corner-test', 'top-right'],
['ticker', 'dock-bottom']
])
})
it('rails reserve the widest railed app; docks reserve nothing sideways', async () => {
const { ambientRailWidth } = await import('../sdk/host.js')
const { defineWidgetApp } = await import('../sdk/registry.js')
const { Text } = await import('@hermes/ink')
const { createElement } = await import('react')
defineWidgetApp({
help: 'wide rail app',
id: 'rail-wide',
mode: 'ambient',
width: 52,
zone: 'top-right',
init: () => ({}),
reduce: state => state,
render: () => createElement(Text, null, 'wide')
})
expect(ambientRailWidth('right')).toBe(0)
launchWidget('corner-test', 'x') // top-right, default width 44
launchWidget('rail-wide', 'x')
launchWidget('ticker', 'x') // dock-bottom — no rail contribution
expect(ambientRailWidth('right')).toBe(52)
expect(ambientRailWidth('left')).toBe(0)
})
it('ambient apps dock together and toggle independently', () => {
expect(launchWidget('ticker', 'eurusd')).toBeNull()
expect(launchWidget('weather', '')).toBeNull()

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, AmbientDock } from '../sdk/host.js'
import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
@ -143,14 +143,15 @@ const TranscriptPane = memo(function TranscriptPane({
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
const ui = useStore($uiState)
const petBox = useStore($petBox)
const railCols = useAmbientRailWidth('left') + useAmbientRailWidth('right')
// Keep transcript text clear of the floating pet, responsively:
// - wide terminals: reserve a right gutter so lines wrap to the pet's left
// (as long as enough width is left for comfortable reading);
// - narrow terminals: keep full width and reserve bottom rows instead, so
// the newest lines sit above the pet rather than getting cramped.
const useGutter = !!petBox && composer.cols - petBox.width >= MIN_GUTTER_BODY_COLS
const bodyCols = useGutter && petBox ? composer.cols - petBox.width : composer.cols
const useGutter = !!petBox && composer.cols - railCols - petBox.width >= MIN_GUTTER_BODY_COLS
const bodyCols = Math.max(28, (useGutter && petBox ? composer.cols - petBox.width : composer.cols) - railCols)
const petBandRows = petBox && !useGutter ? petBox.height : 0
// LiveTodoPanel rides as a child of the latest user-message row so it
@ -362,6 +363,7 @@ const ComposerPane = memo(function ComposerPane({
)}
<StatusRulePane at="top" composer={composer} status={status} />
<AmbientDock placement="dock-top" />
<Box flexDirection="column" marginTop={ui.statusBar === 'top' ? 0 : 1} position="relative">
<FloatingOverlays
@ -442,7 +444,7 @@ const ComposerPane = memo(function ComposerPane({
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}> {ui.status}</Text>}
<AmbientDock />
<AmbientDock placement="dock-bottom" />
<StatusRulePane at="bottom" composer={composer} status={status} />
</NoSelect>
)
@ -530,6 +532,7 @@ export const AppLayout = memo(function AppLayout({
<Shell {...shellProps}>
<Box flexDirection="column" flexGrow={1} position="relative">
<Box flexDirection="row" flexGrow={1}>
{!overlay.agents && !overlay.journey && <AmbientRail side="left" />}
{overlay.agents ? (
<PerfPane id="agents">
<AgentsOverlayPane />
@ -543,6 +546,7 @@ export const AppLayout = memo(function AppLayout({
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</PerfPane>
)}
{!overlay.agents && !overlay.journey && <AmbientRail side="right" />}
</Box>
{!overlay.agents && !overlay.journey && (

View file

@ -1,13 +1,13 @@
import { useStdout } from '@hermes/ink'
import { Box } from '@hermes/ink'
import { Box, Text, useStdout } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { ReactNode } from 'react'
import { Component, type ReactNode } from 'react'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiTheme } from '../app/uiStore.js'
import { recordParentLifecycle } from '../lib/parentLog.js'
import { getWidgetApp } from './registry.js'
import type { ActiveWidget, WidgetApp, WidgetInput } from './types.js'
import type { ActiveWidget, AmbientZone, WidgetApp, WidgetInput } from './types.js'
/**
* The widget-app host. Core integrates through exactly four touchpoints:
@ -130,10 +130,53 @@ export function dispatchWidgetInput(input: WidgetInput): boolean {
return true
}
/** Crash isolation: a widget throwing in render must NEVER take the TUI
* down (user widgets are agent-generated code). The boundary swaps the
* card for a compact error chip and logs; the app stays registered so a
* hot-reloaded fix re-renders on the next state change. */
class WidgetBoundary extends Component<
{ appId: string; children: ReactNode; errorColor: string },
{ message: null | string }
> {
override state: { message: null | string } = { message: null }
static getDerivedStateFromError(error: unknown) {
return { message: error instanceof Error ? error.message : String(error) }
}
override componentDidCatch(error: unknown) {
recordParentLifecycle(
`widget /${this.props.appId} crashed in render: ${error instanceof Error ? error.message : String(error)}`
)
}
override render() {
if (this.state.message !== null) {
return (
<Text color={this.props.errorColor} wrap="truncate-end">
/{this.props.appId}: {this.state.message}
</Text>
)
}
return this.props.children
}
}
const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => {
const app = getWidgetApp(active.appId)
return app ? app.render({ ...ctx, state: active.state as never }) : null
if (!app) {
return null
}
const t = ctx.t as { color: { error: string } }
return (
<WidgetBoundary appId={active.appId} errorColor={t.color.error} key={active.appId}>
{app.render({ ...ctx, state: active.state as never })}
</WidgetBoundary>
)
}
/** Render slot for the MODAL app viewport-level, so it can anchor
@ -150,27 +193,90 @@ export function ActiveWidgetSlot(): ReactNode {
return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never })
}
/** The ambient dock: in-FLOW (never floats over the transcript),
* right-aligned, sitting directly above the status bar GUI-style
* "widgets that just sit there" while the composer stays live. */
export function AmbientDock(): ReactNode {
const overlay = useStore($overlayState)
const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom'
const useAmbientCtx = () => {
const t = useStore($uiTheme)
const { stdout } = useStdout()
if (!overlay.ambient.length) {
return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }
}
/** An in-FLOW dock row: reserves real rows in the chrome (never covers
* content), right-aligned cards. `dock-top` renders under the top status
* bar, `dock-bottom` above the bottom one. */
export function AmbientDock({ placement }: { placement: 'dock-bottom' | 'dock-top' }): ReactNode {
const overlay = useStore($overlayState)
const ctx = useAmbientCtx()
const docked = overlay.ambient.filter(active => zoneOf(active) === placement)
if (!docked.length) {
return null
}
const ctx = { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }
// paddingRight keeps card borders off the terminal's last column — an
// exact-edge border char trips pending-wrap and reads as a clipped border.
return (
<Box columnGap={1} flexDirection="row" justifyContent="flex-end" paddingRight={2} width="100%">
{overlay.ambient.map(active => (
{docked.map(active => (
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
))}
</Box>
)
}
const DEFAULT_RAIL_WIDTH = 44
const railSide = (zone: AmbientZone): 'left' | 'right' | null =>
zone === 'top-left' || zone === 'bottom-left' ? 'left' : zone === 'top-right' || zone === 'bottom-right' ? 'right' : null
const railApps = (ambient: ActiveWidget[], side: 'left' | 'right') =>
ambient.filter(active => railSide(zoneOf(active)) === side)
/** Columns a rail RESERVES (0 when empty) the transcript's width budget
* subtracts this, so widgets genuinely take up space and text reflows
* beside them instead of being painted over. */
export function ambientRailWidth(side: 'left' | 'right', ambient = $overlayState.get().ambient): number {
const apps = railApps(ambient, side)
return apps.length ? Math.max(...apps.map(active => getWidgetApp(active.appId)?.width ?? DEFAULT_RAIL_WIDTH)) : 0
}
/** Live rail width for layout math (re-renders on dock changes). */
export function useAmbientRailWidth(side: 'left' | 'right'): number {
const overlay = useStore($overlayState)
return ambientRailWidth(side, overlay.ambient)
}
/** A side rail: a RESERVED column beside the transcript holding corner
* widgets `top-*` zones anchor to its top, `bottom-*` to its bottom.
* Widgets take real space; nothing overlays content. */
export function AmbientRail({ side }: { side: 'left' | 'right' }): ReactNode {
const overlay = useStore($overlayState)
const ctx = useAmbientCtx()
const apps = railApps(overlay.ambient, side)
if (!apps.length) {
return null
}
const top = apps.filter(active => zoneOf(active).startsWith('top'))
const bottom = apps.filter(active => zoneOf(active).startsWith('bottom'))
const width = ambientRailWidth(side, overlay.ambient)
return (
<Box flexDirection="column" flexShrink={0} justifyContent="space-between" paddingX={1} width={width}>
<Box flexDirection="column" rowGap={1}>
{top.map(active => (
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
))}
</Box>
<Box flexDirection="column" rowGap={1}>
{bottom.map(active => (
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
))}
</Box>
</Box>
)
}

View file

@ -46,7 +46,24 @@ export {
export type { Theme, ThemeColors } from '../theme.js'
// App contract + host
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js'
export {
ActiveWidgetSlot,
AmbientDock,
AmbientRail,
ambientRailWidth,
closeWidget,
dispatchWidgetInput,
launchWidget,
openWidget,
updateWidget
} from './host.js'
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'
export {
type ActiveWidget,
type AmbientZone,
isCtrl,
type WidgetApp,
type WidgetInput,
type WidgetRenderCtx
} from './types.js'
export { loadUserWidgets, type UserWidgetLoadResult, widgetSdk, type WidgetSdk } from './userWidgets.js'

View file

@ -43,12 +43,35 @@ export interface WidgetApp<S = unknown> {
* 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<S>): 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