From 9627d4f43f756ff1f7705e284bfec87113f58312 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:44:13 -0500 Subject: [PATCH] feat(ui-tui): ambient zone system + widget crash boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- skills/productivity/tui-widgets/SKILL.md | 16 ++- ui-tui/src/__tests__/widgetSdk.test.ts | 75 +++++++++++++ ui-tui/src/components/appLayout.tsx | 12 +- ui-tui/src/sdk/host.tsx | 134 ++++++++++++++++++++--- ui-tui/src/sdk/index.ts | 21 +++- ui-tui/src/sdk/types.ts | 23 ++++ 6 files changed, 258 insertions(+), 23 deletions(-) diff --git a/skills/productivity/tui-widgets/SKILL.md b/skills/productivity/tui-widgets/SKILL.md index 2122ef2210c..796f12a370e 100644 --- a/skills/productivity/tui-widgets/SKILL.md +++ b/skills/productivity/tui-widgets/SKILL.md @@ -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. diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index c00d8e5c05a..b68c0709ced 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -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() diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d6a2cefacdb..d6333eccd4a 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, 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) { 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({ )} + ⚕ {ui.status}} - + ) @@ -530,6 +532,7 @@ export const AppLayout = memo(function AppLayout({ + {!overlay.agents && !overlay.journey && } {overlay.agents ? ( @@ -543,6 +546,7 @@ export const AppLayout = memo(function AppLayout({ )} + {!overlay.agents && !overlay.journey && } {!overlay.agents && !overlay.journey && ( diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index 197f77e71ee..cdd44f9700c 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -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 ( + + ⚠ /{this.props.appId}: {this.state.message} + + ) + } + + 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 ( + + {app.render({ ...ctx, state: active.state as never })} + + ) } /** 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 ( - {overlay.ambient.map(active => ( + {docked.map(active => ( {renderApp(active, ctx)} ))} ) } + +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 ( + + + {top.map(active => ( + {renderApp(active, ctx)} + ))} + + + {bottom.map(active => ( + {renderApp(active, ctx)} + ))} + + + ) +} diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 2ca567c5336..6070ce02cdb 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -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' diff --git a/ui-tui/src/sdk/types.ts b/ui-tui/src/sdk/types.ts index e19d6f3ce92..f5ffe90b822 100644 --- a/ui-tui/src/sdk/types.ts +++ b/ui-tui/src/sdk/types.ts @@ -43,12 +43,35 @@ export interface WidgetApp { * 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