From 2ed61d486c98214d57ea66c896e174fe27dc7314 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 13:30:16 -0500 Subject: [PATCH] refactor(ui-tui): host placement router + grid-test width-floor fix host.tsx collapses to one placement router over a shared render context, and the grid-test app drops its width floor too (carrying the #20379 review rule). Final formatting pass folded in. --- ui-tui/src/__tests__/widgetSdk.test.ts | 9 +- ui-tui/src/sdk/apps/dialogTest.tsx | 1 - ui-tui/src/sdk/apps/gridTest.tsx | 5 +- ui-tui/src/sdk/host.tsx | 164 +++++++++++++------------ 4 files changed, 94 insertions(+), 85 deletions(-) diff --git a/ui-tui/src/__tests__/widgetSdk.test.ts b/ui-tui/src/__tests__/widgetSdk.test.ts index b68c0709ced5..ba6022b75e74 100644 --- a/ui-tui/src/__tests__/widgetSdk.test.ts +++ b/ui-tui/src/__tests__/widgetSdk.test.ts @@ -7,13 +7,18 @@ import { getWidgetApp, listWidgetApps } from '../sdk/registry.js' import type { WidgetInput } from '../sdk/types.js' const key = (overrides: Partial = {}, ch = ''): WidgetInput => - ({ ch, key: { ctrl: false, escape: false, leftArrow: false, return: false, rightArrow: false, ...overrides } }) as WidgetInput + ({ + ch, + key: { ctrl: false, escape: false, leftArrow: false, return: false, rightArrow: false, ...overrides } + }) as WidgetInput beforeEach(() => resetOverlayState()) describe('widget SDK host', () => { it('registers the reference apps', () => { - expect(listWidgetApps().map(app => app.id)).toEqual(expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather'])) + expect(listWidgetApps().map(app => app.id)).toEqual( + expect.arrayContaining(['dialog-test', 'grid-test', 'ticker', 'weather']) + ) expect(getWidgetApp('grid-test')).toBe(gridTestApp) }) diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 7f89cae93b74..b8af88e4ecbd 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -64,4 +64,3 @@ export const dialogTestApp = defineWidgetApp({ ) } }) - diff --git a/ui-tui/src/sdk/apps/gridTest.tsx b/ui-tui/src/sdk/apps/gridTest.tsx index 891931bee5ea..955dc20be69f 100644 --- a/ui-tui/src/sdk/apps/gridTest.tsx +++ b/ui-tui/src/sdk/apps/gridTest.tsx @@ -13,7 +13,8 @@ const USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) -const clampSize = (value: number, fallback: number) => (Number.isFinite(value) ? clamp(Math.round(value), 1, MAX_SIZE) : fallback) +const clampSize = (value: number, fallback: number) => + Number.isFinite(value) ? clamp(Math.round(value), 1, MAX_SIZE) : fallback /** null/number cycle: auto → 0 → 1 → … → max → auto. */ const cycleAutoNumber = (value: null | number, max: number) => (value === null ? 0 : value >= max ? null : value + 1) @@ -198,7 +199,7 @@ export const gridTestApp = defineWidgetApp({ return ( - + ) diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index cdd44f9700c8..cf2d611f54b5 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -12,21 +12,35 @@ import type { ActiveWidget, AmbientZone, WidgetApp, WidgetInput } from './types. /** * 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. + * slot (viewport-level), and the AMBIENT surfaces (dock rows + side rails, + * all reserving real space). Everything else — state shape, keybindings, + * presentation — belongs to the app. */ +// ── placement ──────────────────────────────────────────────────────── + const isAmbient = (app: WidgetApp) => app.mode === 'ambient' -const withoutApp = (dock: ActiveWidget[], id: string) => dock.filter(active => active.appId !== id) +const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' -const dockWith = (dock: ActiveWidget[], entry: ActiveWidget) => [...withoutApp(dock, entry.appId), entry] +const withoutApp = (ambient: ActiveWidget[], id: string) => ambient.filter(active => active.appId !== id) + +/** Route a launched app to its slot: ambient apps join the dock array + * (replacing any prior instance), modal apps take the single modal slot. */ +function place(app: WidgetApp, state: unknown): void { + if (isAmbient(app)) { + patchOverlayState({ ambient: [...withoutApp($overlayState.get().ambient, app.id), { appId: app.id, state }] }) + } else { + patchOverlayState({ widget: { appId: app.id, state } }) + } +} + +// ── launch / close / update ────────────────────────────────────────── /** Launch by id. Returns null on success, a printable error/usage line on - * 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. */ + * refusal — the caller owns the transcript. Relaunching an active ambient + * app (with no new argument) toggles it away — ambient apps capture no + * input, so the command is their only dismissal. */ export function launchWidget(id: string, arg = ''): null | string { const app = getWidgetApp(id) @@ -35,10 +49,10 @@ export function launchWidget(id: string, arg = ''): null | string { } if (isAmbient(app)) { - const dock = $overlayState.get().ambient + const ambient = $overlayState.get().ambient - if (dock.some(active => active.appId === id) && !arg.trim()) { - patchOverlayState({ ambient: withoutApp(dock, id) }) + if (ambient.some(active => active.appId === id) && !arg.trim()) { + patchOverlayState({ ambient: withoutApp(ambient, id) }) return null } @@ -50,11 +64,7 @@ export function launchWidget(id: string, arg = ''): null | string { return app.usage ?? `usage: /${id}` } - if (isAmbient(app)) { - patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: id, state }) }) - } else { - patchOverlayState({ widget: { appId: id, state } }) - } + place(app, state) return null } @@ -65,40 +75,30 @@ export const closeWidget = () => patchOverlayState({ widget: null }) /** Programmatic, TYPED launch — bypasses string parsing. Apps use this to * stack each other (the host swaps the active modal app). */ -export function openWidget(app: WidgetApp, state: S): void { - if (isAmbient(app as WidgetApp)) { - patchOverlayState({ ambient: dockWith($overlayState.get().ambient, { appId: app.id, state }) }) - } else { - patchOverlayState({ widget: { appId: app.id, state } }) - } -} +export const openWidget = (app: WidgetApp, state: S): void => place(app as WidgetApp, state) /** 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 + const overlay = $overlayState.get() - if (!dock.some(active => active.appId === app.id)) { - return + if (isAmbient(app as WidgetApp)) { + if (overlay.ambient.some(active => active.appId === app.id)) { + patchOverlayState({ + ambient: overlay.ambient.map(active => + active.appId === app.id ? { appId: app.id, state: fn(active.state as S) } : active + ) + }) } - 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) { - return + if (overlay.widget?.appId === app.id) { + patchOverlayState({ widget: { appId: app.id, state: fn(overlay.widget.state as S) } }) } - - patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } }) } /** Feed one keypress to the active MODAL app (ambient apps capture no @@ -130,6 +130,8 @@ export function dispatchWidgetInput(input: WidgetInput): boolean { return true } +// ── render ─────────────────────────────────────────────────────────── + /** 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 @@ -163,43 +165,52 @@ class WidgetBoundary extends Component< } } -const renderApp = (active: ActiveWidget, ctx: { cols: number; rows: number; t: never }) => { +interface RenderCtx { + cols: number + rows: number + t: never +} + +const useRenderCtx = (): RenderCtx => { + const t = useStore($uiTheme) + const { stdout } = useStdout() + + return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } +} + +const renderApp = (active: ActiveWidget, ctx: RenderCtx) => { const app = getWidgetApp(active.appId) if (!app) { return null } - const t = ctx.t as { color: { error: string } } - return ( - + {app.render({ ...ctx, state: active.state as never })} ) } +const CardStack = ({ apps, ctx }: { apps: ActiveWidget[]; ctx: RenderCtx }) => ( + + {apps.map(active => ( + {renderApp(active, ctx)} + ))} + +) + /** 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) - const t = useStore($uiTheme) - const { stdout } = useStdout() + const ctx = useRenderCtx() - if (!overlay.widget) { - return null - } - - return renderApp(overlay.widget, { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never }) -} - -const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom' - -const useAmbientCtx = () => { - const t = useStore($uiTheme) - const { stdout } = useStdout() - - return { cols: stdout?.columns ?? 80, rows: stdout?.rows ?? 24, t: t as never } + return overlay.widget ? renderApp(overlay.widget, ctx) : null } /** An in-FLOW dock row: reserves real rows in the chrome (never covers @@ -207,7 +218,7 @@ const useAmbientCtx = () => { * 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 ctx = useRenderCtx() const docked = overlay.ambient.filter(active => zoneOf(active) === placement) if (!docked.length) { @@ -225,10 +236,12 @@ export function AmbientDock({ placement }: { placement: 'dock-bottom' | 'dock-to ) } +// ── rails ──────────────────────────────────────────────────────────── + 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 + zone.endsWith('-left') ? 'left' : zone.endsWith('-right') ? 'right' : null const railApps = (ambient: ActiveWidget[], side: 'left' | 'right') => ambient.filter(active => railSide(zoneOf(active)) === side) @@ -244,39 +257,30 @@ export function ambientRailWidth(side: 'left' | 'right', ambient = $overlayState /** 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) + return ambientRailWidth(side, useStore($overlayState).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. */ + * widgets — `top-*` zones stack from its top, `bottom-*` from its bottom. */ export function AmbientRail({ side }: { side: 'left' | 'right' }): ReactNode { const overlay = useStore($overlayState) - const ctx = useAmbientCtx() + const ctx = useRenderCtx() 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)} - ))} - + + zoneOf(active).startsWith('top'))} ctx={ctx} /> + zoneOf(active).startsWith('bottom'))} ctx={ctx} /> ) }