mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
a7e2671639
commit
9627d4f43f
6 changed files with 258 additions and 23 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue