mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
fc3be32a9a
commit
76b58d6127
15 changed files with 267 additions and 51 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
99
ui-tui/src/sdk/apps/ticker.tsx
Normal file
99
ui-tui/src/sdk/apps/ticker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue