mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Merge pull request #68306 from NousResearch/bb/tui-widget-sdk
feat(ui-tui): widget-app SDK — apps as state+reducer+render, with three reference apps
This commit is contained in:
commit
7dc535ad64
34 changed files with 2126 additions and 512 deletions
149
skills/productivity/tui-widgets/SKILL.md
Normal file
149
skills/productivity/tui-widgets/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: tui-widgets
|
||||
description: Author live widget apps for the Hermes TUI dock.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [tui, widgets, sdk, ui]
|
||||
category: productivity
|
||||
---
|
||||
|
||||
# TUI Widgets Skill
|
||||
|
||||
Author widget apps for the Hermes TUI (`hermes --tui`): glanceable ambient
|
||||
panels docked above the status bar, or modal overlays that own the keyboard.
|
||||
Widgets are plain ESM files the TUI loads at startup — no build step, no
|
||||
repo changes. This skill does not cover desktop-app or web-dashboard
|
||||
widgets.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user asks for a live panel in the TUI (ticker, clock, countdown,
|
||||
status card, API-backed readout).
|
||||
- The user wants a custom modal tool (picker, calculator, viewer) bound to
|
||||
a slash command.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The TUI must be in use (`hermes --tui`). Widgets do not render in the
|
||||
classic CLI or messaging platforms.
|
||||
- Network-backed widgets need whatever credentials their API needs; fetch
|
||||
failures must land as an error phase, never a crash.
|
||||
|
||||
## How to Run
|
||||
|
||||
1. Use `write_file` to create `~/.hermes/tui-widgets/<name>.mjs` (see
|
||||
`templates/clock.mjs` for a complete working widget).
|
||||
2. If the TUI is running it hot-loads the file within ~a second (the
|
||||
widgets directory is watched); `/widgets-reload` forces a rescan.
|
||||
3. The widget's id becomes its slash command automatically (`/<id>`), with
|
||||
its `help` in the `/` completion popover. No other registration exists.
|
||||
4. Auto-open (no command needed): end `register(sdk)` with
|
||||
`sdk.openWidget(app, app.init(''))` — the widget docks itself the moment
|
||||
the file loads. Only do this when the user asked for it; note it re-docks
|
||||
on every `/widgets-reload`.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
A widget file default-exports `register(sdk)`:
|
||||
|
||||
```js
|
||||
export default function register(sdk) {
|
||||
const { Box, Text, defineWidgetApp, h } = sdk
|
||||
|
||||
defineWidgetApp({
|
||||
id: 'clock', // slash command name
|
||||
help: 'live clock in the dock', // `/` completion metadata
|
||||
mode: 'ambient', // 'ambient' docks; 'modal' takes input
|
||||
init: arg => ({ label: arg.trim() || 'UTC' }), // null = print usage
|
||||
reduce: (state, { ch, key }) => (key.escape || ch === 'q' ? null : state),
|
||||
render: ({ state, t }) => h(sdk.Dialog, { width: 24 }, h(Text, { color: t.color.label }, state.label))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`sdk` contents: `defineWidgetApp`, `openWidget`, `updateWidget`, `isCtrl`,
|
||||
`React`, `h` (createElement — no JSX in .mjs), components `Box`, `Text`,
|
||||
`Dialog`, `Overlay`, `WidgetGrid`, `GridAreas`, and loaders `Shimmer`,
|
||||
`ShimmerRows`, `useShimmerPhase` — use `ShimmerRows` for loading phases
|
||||
instead of a bare "loading…" line.
|
||||
|
||||
Expand/collapse: `sdk.Accordion` — the same primitive the session panel's
|
||||
tool/skill sections use. `h(Accordion, { t, title: 'details', count: 3,
|
||||
defaultOpen: false }, body)` toggles on CLICK (works in ambient widgets,
|
||||
which receive no keys); modal apps may pass `open` + `onToggle` to drive it
|
||||
from reducer state instead.
|
||||
|
||||
Stable sizing (cards must NEVER resize while ticking):
|
||||
|
||||
- Give `Dialog` an explicit `width`; charts already return exactly the
|
||||
`width` you ask for (short series pad-left while history warms up).
|
||||
- Pad dynamic numbers: `String(v).padStart(6)` — `51 ms` → `112 ms` must
|
||||
not change the line length.
|
||||
- Keep row counts constant per phase; swap content, not structure.
|
||||
|
||||
Charts (pure string builders — color the result with theme tones):
|
||||
|
||||
- `sdk.sparkline(series, width?)` → `▂▃▅▇█▆` one-row trend
|
||||
- `sdk.sparkRows(series, width, rows)` → multi-row column chart (top line
|
||||
first) — the mission-control panel look; taller cells gain resolution
|
||||
- `sdk.gauge(ratio, width)` → `█████░░░` fill bar for a 0..1 value
|
||||
- `sdk.hbars(values, width)` → horizontal bar chart, one bar per value,
|
||||
eighth-block tips, scaled to the max
|
||||
|
||||
Keep a rolling series in component state (push per tick, cap ~120 samples)
|
||||
and render `sparkRows` for dashboard panels, `sparkline` for one-liners.
|
||||
|
||||
Contract essentials:
|
||||
|
||||
- `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.
|
||||
- Async data: fire the fetch from `init`, land results with
|
||||
`sdk.updateWidget(app, fn)` — it no-ops if the widget was closed, so a
|
||||
late reply can never resurrect it.
|
||||
- Animation: own a timer inside a component via `React.useState` +
|
||||
`React.useEffect` (see the template); keep intervals ≥ 250ms.
|
||||
- Colors: ALWAYS theme tones (`t.color.primary/label/muted/ok/error/…`),
|
||||
never hardcoded hexes — widgets must survive `/skin` and light/dark.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Pick `id`, `mode`, and the state shape; keep state serializable.
|
||||
2. Write the file from the template; wire data via `init` + `updateWidget`.
|
||||
3. `/<id>` to launch (hot-loaded on write); relaunch `/<id>` to dismiss an
|
||||
ambient widget.
|
||||
4. Iterate: edit the file — it hot-reloads on save (last-writer-wins, the
|
||||
fresh definition shadows the old one). Relaunch `/<id>` to remount.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- No JSX and no bare imports in `.mjs` — everything comes from the `sdk`
|
||||
parameter; `h(...)` builds elements.
|
||||
- Don't ship a modal without a close path (`Esc`/`q` returning `null`).
|
||||
- Ambient widgets must stay small (≤ ~6 rows) — the dock sits between the
|
||||
transcript and the status bar.
|
||||
- A thrown `register()` is logged and skipped; check
|
||||
`~/.hermes/logs/tui_gateway_crash.log` if a widget never appears.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `/widgets-reload` — the transcript line must list the file under
|
||||
`loaded:`. Then `/<id>`: an ambient widget appears docked right, above the
|
||||
status bar, while the composer keeps accepting input; `/<id>` again removes
|
||||
it.
|
||||
51
skills/productivity/tui-widgets/templates/clock.mjs
Normal file
51
skills/productivity/tui-widgets/templates/clock.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Reference user widget: a live clock docked above the status bar.
|
||||
* Copy to ~/.hermes/tui-widgets/clock.mjs, then `/widgets-reload` and `/clock`.
|
||||
*/
|
||||
export default function register(sdk) {
|
||||
const { Box, Dialog, React, Text, defineWidgetApp, h } = sdk
|
||||
|
||||
function Face({ label, t }) {
|
||||
const [now, setNow] = React.useState(() => new Date())
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return h(
|
||||
Box,
|
||||
{ columnGap: 1, flexDirection: 'row' },
|
||||
h(Text, { bold: true, color: t.color.label }, label),
|
||||
h(Text, { color: t.color.text }, now.toLocaleTimeString('en-GB', { hour12: false, timeZone: label === 'local' ? undefined : label }))
|
||||
)
|
||||
}
|
||||
|
||||
defineWidgetApp({
|
||||
id: 'clock',
|
||||
help: 'live clock in the dock (arg: IANA timezone)',
|
||||
mode: 'ambient',
|
||||
usage: 'usage: /clock [timezone] e.g. /clock UTC · /clock Asia/Tokyo',
|
||||
|
||||
init(arg) {
|
||||
const label = arg.trim() || 'local'
|
||||
|
||||
try {
|
||||
new Date().toLocaleTimeString('en-GB', { timeZone: label === 'local' ? undefined : label })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return { label }
|
||||
},
|
||||
|
||||
reduce(state, { ch, key }) {
|
||||
return key.escape || ch === 'q' ? null : state
|
||||
},
|
||||
|
||||
render({ state, t }) {
|
||||
return h(Dialog, { width: 30 }, h(Face, { label: state.label, t }))
|
||||
}
|
||||
})
|
||||
}
|
||||
47
ui-tui/src/__tests__/charts.test.ts
Normal file
47
ui-tui/src/__tests__/charts.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
|
||||
|
||||
describe('chart primitives', () => {
|
||||
it('sparkline spans the block ramp and respects width', () => {
|
||||
const line = sparkline([0, 1, 2, 3, 4, 5, 6, 7], 8)
|
||||
|
||||
expect(line).toBe('▁▂▃▄▅▆▇█')
|
||||
expect(sparkline([1, 2, 3, 4], 2)).toHaveLength(2) // window = last N
|
||||
})
|
||||
|
||||
it('is dimension-stable: short/empty series pad to exactly width', () => {
|
||||
// Warm-up must never resize the card — latest sample pins right.
|
||||
expect(sparkline([5], 6)).toHaveLength(6)
|
||||
expect(sparkline([5], 6).endsWith('▁')).toBe(true) // flat series → bottom block, right-pinned
|
||||
expect(sparkline([], 6)).toBe(' ')
|
||||
expect(sparkRows([7], 5, 2).every(row => row.length === 5)).toBe(true)
|
||||
expect(sparkRows([], 5, 2)).toEqual([' ', ' '])
|
||||
expect(hbars([1, 4], 8).every(bar => bar.length === 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('sparkRows partitions each column across rows (top line first)', () => {
|
||||
const rows = sparkRows([0, 8, 4], 3, 2)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.every(r => r.length === 3)).toBe(true)
|
||||
// Max value fills the top row cell; min value leaves it blank.
|
||||
expect(rows[0]![1]).toBe('█')
|
||||
expect(rows[0]![0]).toBe(' ')
|
||||
})
|
||||
|
||||
it('gauge clamps and fills proportionally', () => {
|
||||
expect(gauge(0.5, 8)).toBe('████░░░░')
|
||||
expect(gauge(-1, 4)).toBe('░░░░')
|
||||
expect(gauge(9, 4)).toBe('████')
|
||||
})
|
||||
|
||||
it('hbars scales to the max with eighth-block tips', () => {
|
||||
const [half, full] = hbars([4, 8], 8)
|
||||
|
||||
expect(full).toBe('████████')
|
||||
expect(half).toBe('████ ')
|
||||
expect(hbars([3, 8], 8)[0]).toBe('███ ') // 3/8 of 8 cells, padded
|
||||
expect(hbars([1, 2], 3)[0]).toMatch(/^█?[▏▎▍▌▋▊▉█] *$/) // fractional tip, padded
|
||||
})
|
||||
})
|
||||
|
|
@ -74,7 +74,8 @@ describe('createSlashHandler', () => {
|
|||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ describe('createSlashHandler', () => {
|
|||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/grid-test streams')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ streamFocus: 0, streamMain: 0, streams: true })
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import {
|
||||
applyVoiceRecordResponse,
|
||||
dismissSensitivePrompt,
|
||||
handleIdleHotkeyExit,
|
||||
handleStackedModalInput,
|
||||
shouldAllowIdleHotkeyExit,
|
||||
shouldFallThroughForScroll
|
||||
} from '../app/useInputHandlers.js'
|
||||
|
|
@ -146,96 +144,3 @@ describe('dismissSensitivePrompt', () => {
|
|||
await pending
|
||||
})
|
||||
})
|
||||
|
||||
// Review on #20379 (finding 3): a dialog stacked over /grid-test was
|
||||
// visually modal but did not receive input — the grid branch ran first, so
|
||||
// every advertised close key (Esc/q/Enter) mutated the hidden grid instead
|
||||
// of closing the visible dialog. Input routing must follow visual stacking.
|
||||
describe('handleStackedModalInput — dialog over grid-test', () => {
|
||||
const baseModalKey = {
|
||||
ctrl: false,
|
||||
downArrow: false,
|
||||
escape: false,
|
||||
leftArrow: false,
|
||||
return: false,
|
||||
rightArrow: false,
|
||||
upArrow: false
|
||||
}
|
||||
|
||||
const grid: GridTestState = {
|
||||
activeCol: 1,
|
||||
activeRow: 1,
|
||||
areas: false,
|
||||
cols: 4,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: 3,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams: false,
|
||||
zoomed: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ gridTest: { ...grid } })
|
||||
})
|
||||
|
||||
const openDialogViaD = () => {
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'd')).toBe(true)
|
||||
expect(getOverlayState().dialog).not.toBeNull()
|
||||
expect(getOverlayState().gridTest).not.toBeNull()
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Esc', { ...baseModalKey, escape: true }, ''],
|
||||
['q', baseModalKey, 'q'],
|
||||
['Enter', { ...baseModalKey, return: true }, ''],
|
||||
['Ctrl+C', { ...baseModalKey, ctrl: true }, 'c']
|
||||
])('%s closes only the dialog, leaving the grid untouched', (_label, key, ch) => {
|
||||
openDialogViaD()
|
||||
|
||||
const gridBefore = getOverlayState().gridTest
|
||||
|
||||
expect(handleStackedModalInput(getOverlayState(), key, ch)).toBe(true)
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
// The grid must be byte-identical: not closed, not zoomed, not reset.
|
||||
expect(getOverlayState().gridTest).toBe(gridBefore)
|
||||
})
|
||||
|
||||
it('after the dialog closes, the same keys route to the grid again', () => {
|
||||
openDialogViaD()
|
||||
handleStackedModalInput(getOverlayState(), { ...baseModalKey, escape: true }, '')
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
|
||||
// Esc now closes the grid — the dialog no longer shields it.
|
||||
expect(handleStackedModalInput(getOverlayState(), { ...baseModalKey, escape: true }, '')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toBeNull()
|
||||
})
|
||||
|
||||
it('the dialog swallows grid keys entirely while open (no leak-through)', () => {
|
||||
openDialogViaD()
|
||||
|
||||
const gridBefore = getOverlayState().gridTest
|
||||
|
||||
// 'a' toggles areas mode when the grid has focus — it must not now.
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'a')).toBe(true)
|
||||
expect(getOverlayState().gridTest).toBe(gridBefore)
|
||||
expect(getOverlayState().dialog).not.toBeNull()
|
||||
})
|
||||
|
||||
it('stacking works from streams mode too', () => {
|
||||
patchOverlayState({ gridTest: { ...grid, streams: true } })
|
||||
openDialogViaD()
|
||||
|
||||
expect(handleStackedModalInput(getOverlayState(), { ...baseModalKey, return: true }, '')).toBe(true)
|
||||
expect(getOverlayState().dialog).toBeNull()
|
||||
expect(getOverlayState().gridTest?.streams).toBe(true)
|
||||
})
|
||||
|
||||
it('reports unconsumed when neither modal is up', () => {
|
||||
resetOverlayState()
|
||||
expect(handleStackedModalInput(getOverlayState(), baseModalKey, 'x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
67
ui-tui/src/__tests__/userWidgets.test.ts
Normal file
67
ui-tui/src/__tests__/userWidgets.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { launchWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp } from '../sdk/registry.js'
|
||||
import { loadUserWidgets } from '../sdk/userWidgets.js'
|
||||
|
||||
const WIDGET = `
|
||||
export default function register(sdk) {
|
||||
sdk.defineWidgetApp({
|
||||
id: 'test-user-widget',
|
||||
help: 'from disk',
|
||||
mode: 'ambient',
|
||||
init: arg => ({ arg }),
|
||||
reduce: state => state,
|
||||
render: ({ state, t }) => sdk.h(sdk.Text, { color: t.color.label }, state.arg)
|
||||
})
|
||||
}
|
||||
`
|
||||
|
||||
beforeEach(() => resetOverlayState())
|
||||
|
||||
describe('user widget loading', () => {
|
||||
it('missing directory is a clean no-op', async () => {
|
||||
const result = await loadUserWidgets(join(tmpdir(), 'definitely-missing-widgets-dir'))
|
||||
|
||||
expect(result).toEqual({ added: [], errors: [], loaded: [], removed: [] })
|
||||
})
|
||||
|
||||
it('loads .mjs from disk, registers, dispatches, and reports broken files', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
|
||||
|
||||
await writeFile(join(dir, 'good.mjs'), WIDGET)
|
||||
await writeFile(join(dir, 'broken.mjs'), 'export default 42')
|
||||
await writeFile(join(dir, 'ignored.txt'), 'not a widget')
|
||||
|
||||
const result = await loadUserWidgets(dir)
|
||||
|
||||
expect(result.loaded).toEqual(['good.mjs'])
|
||||
expect(result.added).toEqual(['test-user-widget'])
|
||||
expect(result.errors).toMatchObject([{ file: 'broken.mjs' }])
|
||||
|
||||
// Registered like any built-in: catalog metadata + launchable.
|
||||
expect(getWidgetApp('test-user-widget')).toMatchObject({ help: 'from disk', mode: 'ambient' })
|
||||
expect(launchWidget('test-user-widget', 'hi')).toBeNull()
|
||||
expect(getOverlayState().ambient).toMatchObject([{ appId: 'test-user-widget', state: { arg: 'hi' } }])
|
||||
})
|
||||
|
||||
it('a deleted file unregisters its apps on the next scan', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'tui-widgets-'))
|
||||
const file = join(dir, 'gone.mjs')
|
||||
|
||||
await writeFile(file, WIDGET.replace('test-user-widget', 'soon-gone'))
|
||||
await loadUserWidgets(dir)
|
||||
expect(getWidgetApp('soon-gone')).toBeDefined()
|
||||
|
||||
await rm(file)
|
||||
const result = await loadUserWidgets(dir)
|
||||
|
||||
expect(result.removed).toEqual(['soon-gone'])
|
||||
expect(getWidgetApp('soon-gone')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
91
ui-tui/src/__tests__/weatherApp.test.ts
Normal file
91
ui-tui/src/__tests__/weatherApp.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { weatherApp, type WeatherState } from '../sdk/apps/index.js'
|
||||
import { launchWidget } from '../sdk/host.js'
|
||||
import type { WidgetInput } from '../sdk/types.js'
|
||||
|
||||
const key = (overrides: Partial<WidgetInput['key']> = {}, ch = ''): WidgetInput =>
|
||||
({ ch, key: { ctrl: false, escape: false, return: false, ...overrides } }) as WidgetInput
|
||||
|
||||
const wttrReply = (weatherCode: string) => ({
|
||||
current_condition: [
|
||||
{
|
||||
FeelsLikeC: '20',
|
||||
humidity: '40',
|
||||
temp_C: '22',
|
||||
weatherCode,
|
||||
weatherDesc: [{ value: 'Sunny' }],
|
||||
windspeedKmph: '7'
|
||||
}
|
||||
],
|
||||
nearest_area: [{ areaName: [{ value: 'Austin' }], country: [{ value: 'USA' }] }]
|
||||
})
|
||||
|
||||
const activeState = () => getOverlayState().ambient.find(a => a.appId === 'weather')?.state as undefined | WeatherState
|
||||
|
||||
beforeEach(() => resetOverlayState())
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('weather reference app (async contract)', () => {
|
||||
it('launches into loading, lands the fetch via updateWidget', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true }))
|
||||
)
|
||||
|
||||
expect(launchWidget('weather', 'Austin')).toBeNull()
|
||||
expect(activeState()?.phase.kind).toBe('loading')
|
||||
|
||||
await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('ready'))
|
||||
|
||||
const phase = activeState()!.phase
|
||||
|
||||
expect(phase).toMatchObject({ kind: 'ready', report: { area: 'Austin, USA', tempC: '22', weatherCode: 113 } })
|
||||
})
|
||||
|
||||
it('a late resolution cannot resurrect a closed app', async () => {
|
||||
let resolve!: (value: unknown) => void
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => new Promise(r => (resolve = r)))
|
||||
)
|
||||
|
||||
launchWidget('weather', '')
|
||||
expect(activeState()?.phase.kind).toBe('loading')
|
||||
|
||||
// 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().ambient).toEqual([])
|
||||
})
|
||||
|
||||
it('fetch failure lands as an error phase', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ json: async () => ({}), ok: false, status: 503 }))
|
||||
)
|
||||
|
||||
launchWidget('weather', 'nowhere')
|
||||
await vi.waitFor(() => expect(activeState()?.phase.kind).toBe('error'))
|
||||
expect(activeState()?.phase).toMatchObject({ message: expect.stringContaining('503') })
|
||||
})
|
||||
|
||||
it('r refreshes; Esc/q/Enter close', () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ json: async () => wttrReply('113'), ok: true }))
|
||||
)
|
||||
|
||||
const state: WeatherState = { location: 'x', phase: { kind: 'error', message: 'boom' } }
|
||||
|
||||
expect(weatherApp.reduce(state, key({}, 'r'))).toMatchObject({ phase: { kind: 'loading' } })
|
||||
expect(weatherApp.reduce(state, key({ escape: true }))).toBeNull()
|
||||
expect(weatherApp.reduce(state, key({}, 'q'))).toBeNull()
|
||||
expect(weatherApp.reduce(state, key({ return: true }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -4,10 +4,10 @@ import { renderSync, Text } from '@hermes/ink'
|
|||
import React, { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from '../app/interfaces.js'
|
||||
import { GridStreamsDemo, STREAM_DEFS } from '../components/gridStreamsDemo.js'
|
||||
import { GridAreas, type GridAreaWidget, WidgetGrid, type WidgetGridWidget } from '../components/widgetGrid.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
function StatefulCell({ label }: { label: string }) {
|
||||
|
|
|
|||
161
ui-tui/src/__tests__/widgetSdk.test.ts
Normal file
161
ui-tui/src/__tests__/widgetSdk.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { dialogTestApp, gridTestApp } from '../sdk/apps/index.js'
|
||||
import { closeWidget, dispatchWidgetInput, launchWidget, openWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp, listWidgetApps } from '../sdk/registry.js'
|
||||
import type { WidgetInput } from '../sdk/types.js'
|
||||
|
||||
const key = (overrides: Partial<WidgetInput['key']> = {}, ch = ''): 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(getWidgetApp('grid-test')).toBe(gridTestApp)
|
||||
})
|
||||
|
||||
it('launch → dispatch → close lifecycle drives the overlay slot', () => {
|
||||
expect(launchWidget('grid-test', '5x2')).toBeNull()
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ cols: 5, rows: 2 })
|
||||
|
||||
// Reducer output lands back in the slot.
|
||||
expect(dispatchWidgetInput(key({}, 'l'))).toBe(true)
|
||||
expect(getOverlayState().widget?.state).toMatchObject({ activeCol: 1 })
|
||||
|
||||
// null from reduce closes.
|
||||
expect(dispatchWidgetInput(key({ escape: true }))).toBe(true)
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
|
||||
// Nothing active → not handled.
|
||||
expect(dispatchWidgetInput(key({}, 'x'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refused launches return the usage line and leave the slot empty', () => {
|
||||
expect(launchWidget('grid-test', 'not-a-size !')).toBe(gridTestApp.usage)
|
||||
expect(launchWidget('nope', '')).toMatch(/unknown widget app/)
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
it('apps stack each other via the typed programmatic launch', () => {
|
||||
expect(launchWidget('grid-test', '')).toBeNull()
|
||||
|
||||
// `d` swaps the active app to the dialog demo.
|
||||
expect(dispatchWidgetInput(key({}, 'd'))).toBe(true)
|
||||
expect(getOverlayState().widget).toMatchObject({ appId: 'dialog-test' })
|
||||
|
||||
// Enter closes the dialog app.
|
||||
expect(dispatchWidgetInput(key({ return: true }))).toBe(true)
|
||||
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' } })
|
||||
closeWidget()
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
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 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()
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { parseSlashCommand } from '../domain/slash.js'
|
||||
import type { SlashExecResponse } from '../gatewayTypes.js'
|
||||
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { launchWidget } from '../sdk/host.js'
|
||||
import { getWidgetApp } from '../sdk/registry.js'
|
||||
|
||||
import type { SlashHandlerContext } from './interfaces.js'
|
||||
import { findSlashCommand } from './slash/registry.js'
|
||||
|
|
@ -45,6 +47,19 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
|||
return true
|
||||
}
|
||||
|
||||
// Registry-first fallback: widget apps registered AFTER the static
|
||||
// command table was built (user widgets from $HERMES_HOME/tui-widgets,
|
||||
// /widgets-reload) dispatch straight off the live registry.
|
||||
if (getWidgetApp(parsed.name)) {
|
||||
const err = launchWidget(parsed.name, parsed.arg)
|
||||
|
||||
if (err) {
|
||||
sys(err)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (catalog?.canon) {
|
||||
const needle = `/${parsed.name}`.toLowerCase()
|
||||
const exact = Object.entries(catalog.canon).find(([alias]) => alias.toLowerCase() === needle)?.[1]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '../gatewayTypes.js'
|
||||
import type { ParsedVoiceRecordKey } from '../lib/platform.js'
|
||||
import type { RpcResult } from '../lib/rpc.js'
|
||||
import type { ActiveWidget } from '../sdk/types.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type {
|
||||
ApprovalReq,
|
||||
|
|
@ -283,8 +284,10 @@ export interface OverlayState {
|
|||
billing: BillingOverlayState | null
|
||||
clarify: ClarifyReq | null
|
||||
confirm: ConfirmReq | null
|
||||
dialog: DialogState | null
|
||||
gridTest: GridTestState | 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 }
|
||||
pager: null | PagerState
|
||||
|
|
@ -297,41 +300,12 @@ export interface OverlayState {
|
|||
sudo: null | SudoReq
|
||||
}
|
||||
|
||||
export interface DialogState {
|
||||
body: string
|
||||
hint?: string
|
||||
title?: string
|
||||
zone?: 'bottom' | 'bottom-left' | 'bottom-right' | 'center' | 'left' | 'right' | 'top' | 'top-left' | 'top-right'
|
||||
}
|
||||
|
||||
export interface PagerState {
|
||||
lines: string[]
|
||||
offset: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
/** Number of live panels in the /grid-test streams demo (focus wraps mod this). */
|
||||
export const GRID_STREAM_COUNT = 6
|
||||
|
||||
export interface GridTestState {
|
||||
activeCol: number
|
||||
activeRow: number
|
||||
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
|
||||
areas: boolean
|
||||
cols: number
|
||||
gap: null | number
|
||||
nested: boolean
|
||||
paddingX: null | number
|
||||
rows: number
|
||||
/** Streams mode: live-updating panels tiled by GridAreas. */
|
||||
streams: boolean
|
||||
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
|
||||
streamFocus: number
|
||||
/** Streams mode: which panel owns the promoted 2x2 slot. */
|
||||
streamMain: number
|
||||
zoomed: boolean
|
||||
}
|
||||
|
||||
export interface TranscriptRow {
|
||||
index: number
|
||||
key: string
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ const buildOverlayState = (): OverlayState => ({
|
|||
billing: null,
|
||||
clarify: null,
|
||||
confirm: null,
|
||||
dialog: null,
|
||||
gridTest: null,
|
||||
ambient: [],
|
||||
widget: null,
|
||||
journey: false,
|
||||
modelPicker: false,
|
||||
pager: null,
|
||||
|
|
@ -33,8 +33,6 @@ export const $isBlocked = computed(
|
|||
billing,
|
||||
clarify,
|
||||
confirm,
|
||||
dialog,
|
||||
gridTest,
|
||||
journey,
|
||||
modelPicker,
|
||||
pager,
|
||||
|
|
@ -44,7 +42,8 @@ export const $isBlocked = computed(
|
|||
sessions,
|
||||
skillsHub,
|
||||
subscription,
|
||||
sudo
|
||||
sudo,
|
||||
widget
|
||||
}) =>
|
||||
Boolean(
|
||||
agents ||
|
||||
|
|
@ -52,8 +51,6 @@ export const $isBlocked = computed(
|
|||
billing ||
|
||||
clarify ||
|
||||
confirm ||
|
||||
dialog ||
|
||||
gridTest ||
|
||||
journey ||
|
||||
modelPicker ||
|
||||
pager ||
|
||||
|
|
@ -63,7 +60,8 @@ export const $isBlocked = computed(
|
|||
sessions ||
|
||||
skillsHub ||
|
||||
subscription ||
|
||||
sudo
|
||||
sudo ||
|
||||
widget
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -88,8 +86,8 @@ export const resetFlowOverlays = () =>
|
|||
...buildOverlayState(),
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
dialog: $overlayState.get().dialog,
|
||||
gridTest: $overlayState.get().gridTest,
|
||||
ambient: $overlayState.get().ambient,
|
||||
widget: $overlayState.get().widget,
|
||||
journey: $overlayState.get().journey,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
petPicker: $overlayState.get().petPicker,
|
||||
|
|
|
|||
|
|
@ -1,113 +1,45 @@
|
|||
// Importing the apps barrel registers the reference apps before launch.
|
||||
import '../../../sdk/apps/index.js'
|
||||
|
||||
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 { loadUserWidgets } from '../../../sdk/userWidgets.js'
|
||||
import { detectLightMode } from '../../../theme.js'
|
||||
import type { DialogState } from '../../interfaces.js'
|
||||
import { patchOverlayState } from '../../overlayStore.js'
|
||||
import { getUiState } from '../../uiStore.js'
|
||||
import type { SlashCommand } from '../types.js'
|
||||
|
||||
const GRID_TEST_USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
|
||||
const GRID_TEST_MAX_SIZE = 12
|
||||
/** 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(app.id, arg)
|
||||
|
||||
const DIALOG_TEST_ZONES = new Set<DialogState['zone']>([
|
||||
'bottom',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
'center',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'top-left',
|
||||
'top-right'
|
||||
])
|
||||
|
||||
const DIALOG_TEST_USAGE = `usage: /dialog-test [zone] zones: ${[...DIALOG_TEST_ZONES].join(', ')}`
|
||||
|
||||
const clampGridSize = (value: number, fallback: number) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback
|
||||
if (err) {
|
||||
ctx.transcript.sys(err)
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(GRID_TEST_MAX_SIZE, Math.trunc(value)))
|
||||
}
|
||||
|
||||
const parseGridTestSize = (arg: string) => {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { cols: 4, rows: 3 }
|
||||
}
|
||||
|
||||
const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i)
|
||||
|
||||
if (grid) {
|
||||
return { cols: clampGridSize(Number(grid[1]), 4), rows: clampGridSize(Number(grid[2]), 3) }
|
||||
}
|
||||
|
||||
const [cols, rows, ...rest] = trimmed.split(/\s+/)
|
||||
|
||||
if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { cols: clampGridSize(Number(cols), 4), rows: clampGridSize(Number(rows), 3) }
|
||||
}
|
||||
}))
|
||||
|
||||
export const debugCommands: SlashCommand[] = [
|
||||
{
|
||||
help: 'open an interactive widget-grid demo overlay',
|
||||
name: 'grid-test',
|
||||
run: (arg, ctx) => {
|
||||
const streams = arg.trim().toLowerCase() === 'streams'
|
||||
const size = streams ? { cols: 4, rows: 3 } : parseGridTestSize(arg)
|
||||
|
||||
if (!size) {
|
||||
return ctx.transcript.sys(GRID_TEST_USAGE)
|
||||
}
|
||||
|
||||
patchOverlayState({
|
||||
gridTest: {
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols: size.cols,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: size.rows,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams,
|
||||
zoomed: false
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
...widgetAppCommands,
|
||||
|
||||
{
|
||||
help: 'open a sample dialog overlay with a faked backdrop',
|
||||
name: 'dialog-test',
|
||||
run: (arg, ctx) => {
|
||||
const trimmed = arg.trim().toLowerCase()
|
||||
const zone = (trimmed || 'center') as DialogState['zone']
|
||||
help: 'rescan $HERMES_HOME/tui-widgets and (re)register user widget apps',
|
||||
name: 'widgets-reload',
|
||||
run: (_arg, ctx) => {
|
||||
void loadUserWidgets().then(({ errors, loaded }) => {
|
||||
const parts = [
|
||||
loaded.length ? `loaded: ${loaded.join(', ')}` : 'no user widgets found',
|
||||
...errors.map(e => `${e.file}: ${e.message}`)
|
||||
]
|
||||
|
||||
if (!DIALOG_TEST_ZONES.has(zone)) {
|
||||
return ctx.transcript.sys(DIALOG_TEST_USAGE)
|
||||
}
|
||||
|
||||
patchOverlayState({
|
||||
dialog: {
|
||||
body: [
|
||||
'This is a viewport-level overlay with a backdrop.',
|
||||
'',
|
||||
`Zone: ${zone}`,
|
||||
'Try: /dialog-test top-right · bottom · left · ...'
|
||||
].join('\n'),
|
||||
hint: 'Esc/q/Enter close · Ctrl+C close',
|
||||
title: 'Dialog primitive',
|
||||
zone
|
||||
}
|
||||
ctx.transcript.sys(`widgets — ${parts.join(' · ')}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,12 +14,11 @@ import type {
|
|||
import { isAction, isCopyShortcut, isMac, isVoiceToggleKey } from '../lib/platform.js'
|
||||
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
|
||||
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
|
||||
import { closeWidget, dispatchWidgetInput } from '../sdk/host.js'
|
||||
|
||||
import { getInputSelection } from './inputSelectionStore.js'
|
||||
import {
|
||||
type GatewayRpc,
|
||||
GRID_STREAM_COUNT,
|
||||
type GridTestState,
|
||||
type InputHandlerActions,
|
||||
type InputHandlerContext,
|
||||
type InputHandlerResult,
|
||||
|
|
@ -130,160 +129,6 @@ export function dismissSensitivePrompt(
|
|||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
|
||||
const GRID_TEST_MAX_SIZE = 12
|
||||
|
||||
const cycleAutoNumber = (value: null | number, max: number) => {
|
||||
if (value === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return value >= max ? null : value + 1
|
||||
}
|
||||
|
||||
const keepGridCursorInBounds = (grid: GridTestState): GridTestState => ({
|
||||
...grid,
|
||||
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
|
||||
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
|
||||
})
|
||||
|
||||
interface StackedModalKey {
|
||||
ctrl: boolean
|
||||
downArrow: boolean
|
||||
escape: boolean
|
||||
leftArrow: boolean
|
||||
return: boolean
|
||||
rightArrow: boolean
|
||||
upArrow: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Input routing for the stacked demo modals (dialog over grid-test).
|
||||
* Exported so tests can drive the real dispatch against the overlay store.
|
||||
*
|
||||
* ORDER IS THE CONTRACT: input routing follows visual stacking. /grid-test's
|
||||
* `d` opens a dialog ON TOP of the grid without clearing it — the dialog
|
||||
* branch must run first, or Esc/q/Enter mutate the hidden grid instead of
|
||||
* closing the visible dialog, contradicting its "Esc/q/Enter close" hint
|
||||
* (review on #20379, finding 3).
|
||||
*
|
||||
* Returns true when a modal consumed the key (callers stop routing).
|
||||
*/
|
||||
export function handleStackedModalInput(
|
||||
overlay: Pick<OverlayState, 'dialog' | 'gridTest'>,
|
||||
key: StackedModalKey,
|
||||
ch: string
|
||||
): boolean {
|
||||
if (overlay.dialog) {
|
||||
if (key.escape || isCtrl(key, ch, 'c') || ch === 'q' || key.return) {
|
||||
patchOverlayState({ dialog: null })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!overlay.gridTest) {
|
||||
return false
|
||||
}
|
||||
|
||||
const updateGrid = (fn: (grid: GridTestState) => GridTestState) =>
|
||||
patchOverlayState(prev => (prev.gridTest ? { ...prev, gridTest: keepGridCursorInBounds(fn(prev.gridTest)) } : prev))
|
||||
|
||||
const openDemoDialog = () =>
|
||||
patchOverlayState({
|
||||
dialog: {
|
||||
body: ['Dialog overlaid on top of /grid-test.', '', 'Backdrop dims the grid behind.'].join('\n'),
|
||||
hint: 'Esc/q/Enter close',
|
||||
title: 'Overlay primitive',
|
||||
zone: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
const resetGrid = () =>
|
||||
updateGrid(grid => ({
|
||||
...grid,
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols: 4,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows: 3,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams: false,
|
||||
zoomed: false
|
||||
}))
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
patchOverlayState({ gridTest: null })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Streams mode swallows the grid-shape keys: focus cycles across the
|
||||
// live panels and Enter promotes the focused one to the 2x2 slot.
|
||||
if (overlay.gridTest.streams) {
|
||||
if (key.escape || ch === 'q' || ch === 's') {
|
||||
updateGrid(grid => ({ ...grid, streams: false }))
|
||||
} else if (key.return) {
|
||||
updateGrid(grid => ({ ...grid, streamMain: grid.streamFocus }))
|
||||
} else if (ch === 'd') {
|
||||
openDemoDialog()
|
||||
} else if (ch === 'r') {
|
||||
resetGrid()
|
||||
} else if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
|
||||
updateGrid(grid => ({
|
||||
...grid,
|
||||
streamFocus: (grid.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT
|
||||
}))
|
||||
} else if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
|
||||
updateGrid(grid => ({ ...grid, streamFocus: (grid.streamFocus + 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (overlay.gridTest.zoomed && (key.escape || ch === 'q')) {
|
||||
updateGrid(grid => ({ ...grid, zoomed: false }))
|
||||
} else if (key.escape || ch === 'q') {
|
||||
patchOverlayState({ gridTest: null })
|
||||
} else if (key.return) {
|
||||
updateGrid(grid => ({ ...grid, nested: true, zoomed: true }))
|
||||
} else if (ch === 'n') {
|
||||
updateGrid(grid => ({ ...grid, nested: !grid.nested }))
|
||||
} else if (ch === 'a') {
|
||||
updateGrid(grid => ({ ...grid, areas: !grid.areas, streams: false }))
|
||||
} else if (ch === 's') {
|
||||
updateGrid(grid => ({ ...grid, areas: false, streams: true }))
|
||||
} else if (ch === 'g') {
|
||||
updateGrid(grid => ({ ...grid, gap: cycleAutoNumber(grid.gap, 3) }))
|
||||
} else if (ch === 'p') {
|
||||
updateGrid(grid => ({ ...grid, paddingX: cycleAutoNumber(grid.paddingX, 2) }))
|
||||
} else if (ch === 'd') {
|
||||
openDemoDialog()
|
||||
} else if (ch === 'r') {
|
||||
resetGrid()
|
||||
} else if (ch === '+' || ch === '=') {
|
||||
updateGrid(grid => ({ ...grid, cols: clamp(grid.cols + 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === '-' || ch === '_') {
|
||||
updateGrid(grid => ({ ...grid, cols: clamp(grid.cols - 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === ']') {
|
||||
updateGrid(grid => ({ ...grid, rows: clamp(grid.rows + 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (ch === '[') {
|
||||
updateGrid(grid => ({ ...grid, rows: clamp(grid.rows - 1, 1, GRID_TEST_MAX_SIZE) }))
|
||||
} else if (key.leftArrow || ch === 'h') {
|
||||
updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol - 1, 0, grid.cols - 1) }))
|
||||
} else if (key.rightArrow || ch === 'l') {
|
||||
updateGrid(grid => ({ ...grid, activeCol: clamp(grid.activeCol + 1, 0, grid.cols - 1) }))
|
||||
} else if (key.upArrow || ch === 'k') {
|
||||
updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow - 1, 0, grid.rows - 1) }))
|
||||
} else if (key.downArrow || ch === 'j') {
|
||||
updateGrid(grid => ({ ...grid, activeRow: clamp(grid.activeRow + 1, 0, grid.rows - 1) }))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
const { actions, composer, gateway, terminal, voice, wheelStep } = ctx
|
||||
|
|
@ -377,12 +222,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return patchOverlayState({ journey: false })
|
||||
}
|
||||
|
||||
if (overlay.gridTest) {
|
||||
return patchOverlayState({ gridTest: null })
|
||||
}
|
||||
|
||||
if (overlay.dialog) {
|
||||
return patchOverlayState({ dialog: null })
|
||||
if (overlay.widget) {
|
||||
return closeWidget()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -567,9 +408,11 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||
return
|
||||
}
|
||||
|
||||
// Stacked demo modals (dialog over grid-test): shared routing where
|
||||
// the topmost visual layer consumes input first.
|
||||
if (handleStackedModalInput(overlay, key, ch)) {
|
||||
// Widget apps (SDK): the active app owns every key while open. This
|
||||
// supersedes the demo-only handleStackedModalInput routing from #68999
|
||||
// — grid-test/dialog are now widget apps, so the topmost-modal-owns-
|
||||
// input contract is enforced structurally by the single active widget.
|
||||
if (overlay.widget && dispatchWidgetInput({ ch, key })) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
|||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
|
||||
import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js'
|
||||
import { onUserWidgets } from '../sdk/userWidgets.js'
|
||||
import type { Msg, PanelSection, SlashCatalog } from '../types.js'
|
||||
|
||||
import { createGatewayEventHandler } from './createGatewayEventHandler.js'
|
||||
|
|
@ -435,6 +436,26 @@ export function useMainApp(gw: GatewayClient) {
|
|||
|
||||
const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage])
|
||||
|
||||
// Hot-loaded user widgets announce themselves — a silently-registered
|
||||
// widget is indistinguishable from a failed one. Errors surface too.
|
||||
useEffect(
|
||||
() =>
|
||||
onUserWidgets(({ added, errors, removed }) => {
|
||||
for (const id of added) {
|
||||
sys(`widget /${id} is live — type /${id} to open`)
|
||||
}
|
||||
|
||||
for (const id of removed) {
|
||||
sys(`widget /${id} removed (file deleted)`)
|
||||
}
|
||||
|
||||
for (const err of errors) {
|
||||
sys(`widget ${err.file} failed to load: ${err.message}`)
|
||||
}
|
||||
}),
|
||||
[sys]
|
||||
)
|
||||
|
||||
const page = useCallback(
|
||||
(text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }),
|
||||
[]
|
||||
|
|
|
|||
58
ui-tui/src/components/accordion.tsx
Normal file
58
ui-tui/src/components/accordion.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
/**
|
||||
* THE expand/collapse primitive — the session panel's tool/skill sections
|
||||
* and widget-app accordions are the same component. Click the header to
|
||||
* toggle (mouse works even in ambient widgets, which receive no keys);
|
||||
* modal apps may instead drive `open` from reducer state (controlled).
|
||||
* Uncontrolled by default: pass `defaultOpen` and forget it.
|
||||
*/
|
||||
export function Accordion({
|
||||
children,
|
||||
count,
|
||||
defaultOpen = false,
|
||||
onToggle,
|
||||
open,
|
||||
suffix,
|
||||
t,
|
||||
title
|
||||
}: {
|
||||
children: ReactNode
|
||||
count?: number
|
||||
defaultOpen?: boolean
|
||||
/** Controlled open state; omit for internal (click-toggled) state. */
|
||||
open?: boolean
|
||||
onToggle?: () => void
|
||||
suffix?: string
|
||||
t: Theme
|
||||
title: string
|
||||
}) {
|
||||
const [uncontrolled, setUncontrolled] = useState(defaultOpen)
|
||||
const isOpen = open ?? uncontrolled
|
||||
|
||||
const toggle = () => {
|
||||
onToggle?.()
|
||||
|
||||
if (open === undefined) {
|
||||
setUncontrolled(v => !v)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box onClick={toggle}>
|
||||
<Text color={t.color.accent}>{isOpen ? '▾ ' : '▸ '}</Text>
|
||||
<Text bold color={t.color.accent}>
|
||||
{title}
|
||||
</Text>
|
||||
{typeof count === 'number' ? <Text color={t.color.muted}> ({count})</Text> : null}
|
||||
{suffix ? <Text color={t.color.muted}> {suffix}</Text> : null}
|
||||
</Box>
|
||||
|
||||
{isOpen ? children : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
// Importing the apps barrel registers the reference widget apps at startup.
|
||||
import '../sdk/apps/index.js'
|
||||
|
||||
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Fragment, memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
|
@ -19,6 +22,7 @@ import {
|
|||
} from '../lib/inputMetrics.js'
|
||||
import { PerfPane } from '../lib/perfPane.js'
|
||||
import { composerPromptText } from '../lib/prompt.js'
|
||||
import { ActiveWidgetSlot, AmbientDock, AmbientRail, useAmbientRailWidth } from '../sdk/host.js'
|
||||
|
||||
import { AgentsOverlay } from './agentsOverlay.js'
|
||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||
|
|
@ -28,7 +32,6 @@ import { FpsOverlay } from './fpsOverlay.js'
|
|||
import { HelpHint } from './helpHint.js'
|
||||
import { Journey } from './journey.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { Dialog, Overlay } from './overlay.js'
|
||||
import { PetKitty, PetSprite } from './petSprite.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||
|
|
@ -140,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
|
||||
|
|
@ -359,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
|
||||
|
|
@ -439,6 +444,7 @@ const ComposerPane = memo(function ComposerPane({
|
|||
|
||||
{!composer.empty && !ui.sid && <Text color={ui.theme.color.muted}>⚕ {ui.status}</Text>}
|
||||
|
||||
<AmbientDock placement="dock-bottom" />
|
||||
<StatusRulePane at="bottom" composer={composer} status={status} />
|
||||
</NoSelect>
|
||||
)
|
||||
|
|
@ -526,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 />
|
||||
|
|
@ -539,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 && (
|
||||
|
|
@ -568,19 +576,7 @@ export const AppLayout = memo(function AppLayout({
|
|||
{!overlay.agents && <PetPane />}
|
||||
</Box>
|
||||
|
||||
{overlay.dialog && (
|
||||
<Overlay backdrop zone={overlay.dialog.zone ?? 'center'}>
|
||||
<Dialog
|
||||
hint={overlay.dialog.hint ?? 'Esc/q close'}
|
||||
title={overlay.dialog.title}
|
||||
width={Math.min(60, composer.cols - 8)}
|
||||
>
|
||||
{overlay.dialog.body.split('\n').map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
)}
|
||||
<ActiveWidgetSlot />
|
||||
</Shell>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { $uiSessionId, $uiTheme } from '../app/uiStore.js'
|
|||
import { ActiveSessionSwitcher } from './activeSessionSwitcher.js'
|
||||
import { FloatBox } from './appChrome.js'
|
||||
import { BillingOverlay } from './billingOverlay.js'
|
||||
import { GridTestOverlay } from './gridTestOverlay.js'
|
||||
import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { OverlayHint } from './overlayControls.js'
|
||||
|
|
@ -193,7 +192,6 @@ export function FloatingOverlays({
|
|||
const theme = useStore($uiTheme)
|
||||
|
||||
const hasAny =
|
||||
overlay.gridTest ||
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.petPicker ||
|
||||
|
|
@ -220,22 +218,6 @@ export function FloatingOverlays({
|
|||
// column it never binds, so rendering is identical to the pre-grid layout.
|
||||
const widgets: WidgetGridWidget[] = []
|
||||
|
||||
const gridTest = overlay.gridTest
|
||||
|
||||
if (gridTest) {
|
||||
widgets.push({
|
||||
id: 'grid-test',
|
||||
render: () => (
|
||||
<FloatBox color={theme.color.border}>
|
||||
{/* cols-6 = FloatBox chrome (4) + margin (2); no 24-col floor —
|
||||
forcing one would overflow cells narrower than 28 and clip at
|
||||
the terminal edge. */}
|
||||
<GridTestOverlay cols={Math.max(1, cols - 6)} state={gridTest} t={theme} />
|
||||
</FloatBox>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (overlay.sessions) {
|
||||
widgets.push({
|
||||
id: 'sessions',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { flat } from '../lib/text.js'
|
|||
import type { Theme } from '../theme.js'
|
||||
import type { PanelSection, SessionInfo } from '../types.js'
|
||||
|
||||
import { Accordion } from './accordion.js'
|
||||
import { ShimmerRows } from './loaders.js'
|
||||
import { WidgetGrid } from './widgetGrid.js'
|
||||
|
||||
|
|
@ -203,35 +204,6 @@ const SKELETON_ROWS: readonly (readonly [number, number])[] = [
|
|||
[10, 13]
|
||||
]
|
||||
|
||||
// ── Collapsible helpers ──────────────────────────────────────────────
|
||||
|
||||
function CollapseToggle({
|
||||
count,
|
||||
open,
|
||||
suffix,
|
||||
t,
|
||||
title,
|
||||
onToggle
|
||||
}: {
|
||||
count?: number
|
||||
open: boolean
|
||||
suffix?: string
|
||||
t: Theme
|
||||
title: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<Box onClick={onToggle}>
|
||||
<Text color={t.color.accent}>{open ? '▾ ' : '▸ '}</Text>
|
||||
<Text bold color={t.color.accent}>
|
||||
{title}
|
||||
</Text>
|
||||
{typeof count === 'number' ? <Text color={t.color.muted}> ({count})</Text> : null}
|
||||
{suffix ? <Text color={t.color.muted}> {suffix}</Text> : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── SessionPanel ─────────────────────────────────────────────────────
|
||||
|
||||
const SKILLS_MAX = 8
|
||||
|
|
@ -431,49 +403,53 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
|
|||
|
||||
{/* ── Tools (expanded by default) ── */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<CollapseToggle onToggle={() => setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools" />
|
||||
{toolsOpen && toolsBody()}
|
||||
<Accordion onToggle={() => setToolsOpen(v => !v)} open={toolsOpen} t={t} title="Available Tools">
|
||||
{toolsBody()}
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
{/* ── Skills (collapsed by default) ── */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<CollapseToggle
|
||||
<Accordion
|
||||
count={skillsTotal}
|
||||
onToggle={() => setSkillsOpen(v => !v)}
|
||||
open={skillsOpen}
|
||||
suffix={skillsCatCount > 0 ? `in ${skillsCatCount} categor${skillsCatCount === 1 ? 'y' : 'ies'}` : undefined}
|
||||
t={t}
|
||||
title="Available Skills"
|
||||
/>
|
||||
{skillsOpen && skillsBody()}
|
||||
>
|
||||
{skillsBody()}
|
||||
</Accordion>
|
||||
</Box>
|
||||
|
||||
{/* ── System Prompt (collapsed by default) ── */}
|
||||
{sysPromptLen > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<CollapseToggle
|
||||
<Accordion
|
||||
onToggle={() => setSystemOpen(v => !v)}
|
||||
open={systemOpen}
|
||||
suffix={`— ${sysPromptLen.toLocaleString()} chars`}
|
||||
t={t}
|
||||
title="System Prompt"
|
||||
/>
|
||||
{systemOpen && systemBody()}
|
||||
>
|
||||
{systemBody()}
|
||||
</Accordion>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* ── MCP Servers (collapsed by default) ── */}
|
||||
{mcpServers.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<CollapseToggle
|
||||
<Accordion
|
||||
count={mcpConnected}
|
||||
onToggle={() => setMcpOpen(v => !v)}
|
||||
open={mcpOpen}
|
||||
suffix="connected"
|
||||
t={t}
|
||||
title="MCP Servers"
|
||||
/>
|
||||
{mcpOpen && mcpBody()}
|
||||
>
|
||||
{mcpBody()}
|
||||
</Accordion>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import { sparkRows } from '../lib/charts.js'
|
||||
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
|
||||
import type { GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { GridAreas, type GridAreaWidget } from './widgetGrid.js'
|
||||
|
|
@ -18,8 +19,6 @@ const HEADER_ROWS = 3
|
|||
const STREAM_ROWS = 3
|
||||
const GRID_HEIGHT = HEADER_ROWS + STREAM_ROWS * 6
|
||||
|
||||
const SPARK_CHARS = '▁▂▃▄▅▆▇█'
|
||||
|
||||
interface StreamDef {
|
||||
id: string
|
||||
render: (inner: { height: number; t: Theme; width: number }) => ReactNode
|
||||
|
|
@ -57,37 +56,6 @@ const useHistory = (tick: number, sample: () => number, cap = 240) => {
|
|||
return historyRef.current
|
||||
}
|
||||
|
||||
/**
|
||||
* Render history as a column chart `rows` lines tall (top line first). Each
|
||||
* column gets `rows * 8` vertical levels — full blocks below the value, a
|
||||
* partial eighth-block at the value row, spaces above — so a promoted (taller)
|
||||
* cell genuinely gains chart resolution rather than just whitespace.
|
||||
*/
|
||||
const sparkRows = (history: number[], width: number, rows: number): string[] => {
|
||||
const window = history.slice(-Math.max(1, width))
|
||||
|
||||
if (!window.length) {
|
||||
return Array.from({ length: rows }, () => '')
|
||||
}
|
||||
|
||||
const min = Math.min(...window)
|
||||
const max = Math.max(...window)
|
||||
const range = max - min || 1
|
||||
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
|
||||
|
||||
return Array.from({ length: rows }, (_, lineIdx) => {
|
||||
const rowFromBottom = rows - 1 - lineIdx
|
||||
|
||||
return levels
|
||||
.map(level => {
|
||||
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
|
||||
|
||||
return filled === 0 ? ' ' : SPARK_CHARS[filled - 1]
|
||||
})
|
||||
.join('')
|
||||
})
|
||||
}
|
||||
|
||||
// ── stream panels ───────────────────────────────────────────────────────────
|
||||
|
||||
const TOKEN_WORDS = (
|
||||
|
|
@ -311,8 +279,10 @@ function StreamPanel({
|
|||
paddingX={1}
|
||||
width={cell.width}
|
||||
>
|
||||
{/* No phantom icon column: unfocused titles sit flush left — the ▸
|
||||
appears (and shifts the title) only while focused. */}
|
||||
<Text bold={focused} color={focused ? t.color.primary : t.color.label} wrap="truncate">
|
||||
{focused ? '▸ ' : ' '}
|
||||
{focused ? '▸ ' : ''}
|
||||
{title}
|
||||
{main ? ' ·' : ''}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import type { GridTestState } from '../app/interfaces.js'
|
||||
import type { GridAreaCell, GridTrackSize } from '../lib/widgetGrid.js'
|
||||
import type { GridTestState } from '../sdk/apps/gridTestState.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { GridStreamsDemo } from './gridStreamsDemo.js'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
80
ui-tui/src/lib/charts.ts
Normal file
80
ui-tui/src/lib/charts.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Chart primitives — pure string builders (no React), THE charting layer for
|
||||
* widget apps and demos alike. Everything returns plain strings the caller
|
||||
* colors with theme tones; everything auto-scales to the series' min/max.
|
||||
*/
|
||||
|
||||
const BLOCKS = '▁▂▃▄▅▆▇█'
|
||||
|
||||
const normalize = (series: number[], window: number): { min: number; range: number; window: number[] } => {
|
||||
const view = series.slice(-Math.max(1, window))
|
||||
const min = Math.min(...view)
|
||||
|
||||
return { min, range: Math.max(...view) - min || 1, window: view }
|
||||
}
|
||||
|
||||
/** One-row sparkline: `▂▃▅▇█▆…`, last `width` samples. ALWAYS exactly
|
||||
* `width` cells — short series pad-left so the card never resizes while
|
||||
* history warms up (latest sample stays pinned to the right edge). */
|
||||
export function sparkline(series: number[], width = series.length): string {
|
||||
if (!series.length) {
|
||||
return ' '.repeat(Math.max(0, width))
|
||||
}
|
||||
|
||||
const { min, range, window } = normalize(series, width)
|
||||
|
||||
return window
|
||||
.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / range) * BLOCKS.length))])
|
||||
.join('')
|
||||
.padStart(width)
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-row column chart, top line first — the streams-demo panel chart.
|
||||
* Each column resolves to `rows * 8` vertical levels (full blocks below the
|
||||
* value, a partial eighth-block at it), so taller cells genuinely gain
|
||||
* resolution.
|
||||
*/
|
||||
export function sparkRows(series: number[], width: number, rows: number): string[] {
|
||||
if (!series.length) {
|
||||
return Array.from({ length: rows }, () => ' '.repeat(width))
|
||||
}
|
||||
|
||||
const { min, range, window } = normalize(series, width)
|
||||
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
|
||||
|
||||
return Array.from({ length: rows }, (_, lineIdx) => {
|
||||
const rowFromBottom = rows - 1 - lineIdx
|
||||
|
||||
return levels
|
||||
.map(level => {
|
||||
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
|
||||
|
||||
return filled === 0 ? ' ' : BLOCKS[filled - 1]
|
||||
})
|
||||
.join('')
|
||||
.padStart(width) // warm-up pads left: chart grows from the right, card never resizes
|
||||
})
|
||||
}
|
||||
|
||||
/** Horizontal fill gauge: `█████░░░` for a 0..1 ratio. */
|
||||
export function gauge(ratio: number, width: number): string {
|
||||
const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
|
||||
|
||||
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
|
||||
}
|
||||
|
||||
/** Horizontal bar chart: one `███▌`-style bar per value, scaled to the max,
|
||||
* each padded to exactly `width` (stable card sizing). Eighth-block tips
|
||||
* keep adjacent values distinguishable. */
|
||||
export function hbars(values: number[], width: number): string[] {
|
||||
const max = Math.max(...values, 0) || 1
|
||||
|
||||
return values.map(v => {
|
||||
const cells = (Math.min(max, Math.max(0, v)) / max) * width
|
||||
const full = Math.floor(cells)
|
||||
const rest = Math.round((cells - full) * 8)
|
||||
|
||||
return ('█'.repeat(full) + (rest > 0 ? '▏▎▍▌▋▊▉█'[rest - 1] : '')).padEnd(width)
|
||||
})
|
||||
}
|
||||
66
ui-tui/src/sdk/apps/dialogTest.tsx
Normal file
66
ui-tui/src/sdk/apps/dialogTest.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Text } from '@hermes/ink'
|
||||
|
||||
import { Dialog, Overlay, type OverlayZone } from '../../components/overlay.js'
|
||||
import { defineWidgetApp } from '../registry.js'
|
||||
import { isCtrl } from '../types.js'
|
||||
|
||||
const ZONES: readonly OverlayZone[] = [
|
||||
'bottom',
|
||||
'bottom-left',
|
||||
'bottom-right',
|
||||
'center',
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'top-left',
|
||||
'top-right'
|
||||
]
|
||||
|
||||
const USAGE = `usage: /dialog-test [zone] zones: ${ZONES.join(', ')}`
|
||||
|
||||
export interface DialogTestState {
|
||||
body: string
|
||||
hint?: string
|
||||
title?: string
|
||||
zone: OverlayZone
|
||||
}
|
||||
|
||||
const defaultBody = (zone: OverlayZone) =>
|
||||
[
|
||||
'This is a viewport-level overlay with a backdrop.',
|
||||
'',
|
||||
`Zone: ${zone}`,
|
||||
'Try: /dialog-test top-right · bottom · left · ...'
|
||||
].join('\n')
|
||||
|
||||
export const dialogTestApp = defineWidgetApp<DialogTestState>({
|
||||
id: 'dialog-test',
|
||||
help: 'open a sample dialog overlay with a faked backdrop',
|
||||
usage: USAGE,
|
||||
|
||||
init(arg) {
|
||||
const zone = (arg.trim().toLowerCase() || 'center') as OverlayZone
|
||||
|
||||
if (!ZONES.includes(zone)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { body: defaultBody(zone), hint: 'Esc/q/Enter close · Ctrl+C close', title: 'Dialog primitive', zone }
|
||||
},
|
||||
|
||||
reduce(state, { ch, key }) {
|
||||
return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state
|
||||
},
|
||||
|
||||
render({ cols, state }) {
|
||||
return (
|
||||
<Overlay backdrop zone={state.zone}>
|
||||
<Dialog hint={state.hint ?? 'Esc/q close'} title={state.title} width={Math.min(60, cols - 8)}>
|
||||
{state.body.split('\n').map((line, i) => (
|
||||
<Text key={i}>{line || ' '}</Text>
|
||||
))}
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
})
|
||||
207
ui-tui/src/sdk/apps/gridTest.tsx
Normal file
207
ui-tui/src/sdk/apps/gridTest.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { FloatBox } from '../../components/appChrome.js'
|
||||
import { GridTestOverlay } from '../../components/gridTestOverlay.js'
|
||||
import { Overlay } from '../../components/overlay.js'
|
||||
import { openWidget } from '../host.js'
|
||||
import { defineWidgetApp } from '../registry.js'
|
||||
import { isCtrl, type WidgetInput } from '../types.js'
|
||||
|
||||
import { dialogTestApp } from './dialogTest.js'
|
||||
import { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
|
||||
|
||||
const MAX_SIZE = 12
|
||||
const USAGE = 'usage: /grid-test [cols]x[rows] · /grid-test [cols] [rows] · /grid-test streams'
|
||||
|
||||
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
|
||||
|
||||
/** null/number cycle: auto → 0 → 1 → … → max → auto. */
|
||||
const cycleAutoNumber = (value: null | number, max: number) => (value === null ? 0 : value >= max ? null : value + 1)
|
||||
|
||||
const keepCursorInBounds = (grid: GridTestState): GridTestState => ({
|
||||
...grid,
|
||||
activeCol: clamp(grid.activeCol, 0, grid.cols - 1),
|
||||
activeRow: clamp(grid.activeRow, 0, grid.rows - 1)
|
||||
})
|
||||
|
||||
const initialState = (cols: number, rows: number, streams: boolean): GridTestState => ({
|
||||
activeCol: 0,
|
||||
activeRow: 0,
|
||||
areas: false,
|
||||
cols,
|
||||
gap: null,
|
||||
nested: false,
|
||||
paddingX: null,
|
||||
rows,
|
||||
streamFocus: 0,
|
||||
streamMain: 0,
|
||||
streams,
|
||||
zoomed: false
|
||||
})
|
||||
|
||||
function parseSize(arg: string): null | { cols: number; rows: number } {
|
||||
const trimmed = arg.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { cols: 4, rows: 3 }
|
||||
}
|
||||
|
||||
const grid = trimmed.match(/^(\d+)\s*x\s*(\d+)$/i)
|
||||
|
||||
if (grid) {
|
||||
return { cols: clampSize(Number(grid[1]), 4), rows: clampSize(Number(grid[2]), 3) }
|
||||
}
|
||||
|
||||
const [cols, rows, ...rest] = trimmed.split(/\s+/)
|
||||
|
||||
if (rest.length || !cols || !rows || Number.isNaN(Number(cols)) || Number.isNaN(Number(rows))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { cols: clampSize(Number(cols), 4), rows: clampSize(Number(rows), 3) }
|
||||
}
|
||||
|
||||
const update = (grid: GridTestState, fn: (grid: GridTestState) => GridTestState) => keepCursorInBounds(fn(grid))
|
||||
|
||||
function reduceStreams(grid: GridTestState, { ch, key }: WidgetInput): GridTestState | null {
|
||||
if (key.escape || ch === 'q' || ch === 's') {
|
||||
return update(grid, g => ({ ...g, streams: false }))
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return update(grid, g => ({ ...g, streamMain: g.streamFocus }))
|
||||
}
|
||||
|
||||
if (ch === 'r') {
|
||||
return initialState(4, 3, false)
|
||||
}
|
||||
|
||||
if (key.leftArrow || key.upArrow || ch === 'h' || ch === 'k') {
|
||||
return update(grid, g => ({ ...g, streamFocus: (g.streamFocus + GRID_STREAM_COUNT - 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
if (key.rightArrow || key.downArrow || ch === 'l' || ch === 'j') {
|
||||
return update(grid, g => ({ ...g, streamFocus: (g.streamFocus + 1) % GRID_STREAM_COUNT }))
|
||||
}
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
export const gridTestApp = defineWidgetApp<GridTestState>({
|
||||
id: 'grid-test',
|
||||
help: 'open an interactive widget-grid demo overlay',
|
||||
usage: USAGE,
|
||||
|
||||
init(arg) {
|
||||
const streams = arg.trim().toLowerCase() === 'streams'
|
||||
const size = streams ? { cols: 4, rows: 3 } : parseSize(arg)
|
||||
|
||||
return size ? initialState(size.cols, size.rows, streams) : null
|
||||
},
|
||||
|
||||
reduce(grid, input) {
|
||||
const { ch, key } = input
|
||||
|
||||
if (isCtrl(key, ch, 'c')) {
|
||||
return null
|
||||
}
|
||||
|
||||
// `d` opens the dialog app as a nested demo — apps launch each other via
|
||||
// the typed programmatic API; the host swaps the active app.
|
||||
if (ch === 'd') {
|
||||
openWidget(dialogTestApp, {
|
||||
body: 'Dialog overlaid on top of /grid-test.\n\nBackdrop dims the grid behind.',
|
||||
hint: 'Esc/q/Enter close',
|
||||
title: 'Overlay primitive',
|
||||
zone: 'center'
|
||||
})
|
||||
|
||||
return grid
|
||||
}
|
||||
|
||||
if (grid.streams) {
|
||||
return reduceStreams(grid, input)
|
||||
}
|
||||
|
||||
if (grid.zoomed && (key.escape || ch === 'q')) {
|
||||
return update(grid, g => ({ ...g, zoomed: false }))
|
||||
}
|
||||
|
||||
if (key.escape || ch === 'q') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
return update(grid, g => ({ ...g, nested: true, zoomed: true }))
|
||||
}
|
||||
|
||||
if (ch === 'n') {
|
||||
return update(grid, g => ({ ...g, nested: !g.nested }))
|
||||
}
|
||||
|
||||
if (ch === 'a') {
|
||||
return update(grid, g => ({ ...g, areas: !g.areas, streams: false }))
|
||||
}
|
||||
|
||||
if (ch === 's') {
|
||||
return update(grid, g => ({ ...g, areas: false, streams: true }))
|
||||
}
|
||||
|
||||
if (ch === 'g') {
|
||||
return update(grid, g => ({ ...g, gap: cycleAutoNumber(g.gap, 3) }))
|
||||
}
|
||||
|
||||
if (ch === 'p') {
|
||||
return update(grid, g => ({ ...g, paddingX: cycleAutoNumber(g.paddingX, 2) }))
|
||||
}
|
||||
|
||||
if (ch === 'r') {
|
||||
return initialState(4, 3, false)
|
||||
}
|
||||
|
||||
if (ch === '+' || ch === '=') {
|
||||
return update(grid, g => ({ ...g, cols: clamp(g.cols + 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === '-' || ch === '_') {
|
||||
return update(grid, g => ({ ...g, cols: clamp(g.cols - 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === ']') {
|
||||
return update(grid, g => ({ ...g, rows: clamp(g.rows + 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (ch === '[') {
|
||||
return update(grid, g => ({ ...g, rows: clamp(g.rows - 1, 1, MAX_SIZE) }))
|
||||
}
|
||||
|
||||
if (key.leftArrow || ch === 'h') {
|
||||
return update(grid, g => ({ ...g, activeCol: g.activeCol - 1 }))
|
||||
}
|
||||
|
||||
if (key.rightArrow || ch === 'l') {
|
||||
return update(grid, g => ({ ...g, activeCol: g.activeCol + 1 }))
|
||||
}
|
||||
|
||||
if (key.upArrow || ch === 'k') {
|
||||
return update(grid, g => ({ ...g, activeRow: g.activeRow - 1 }))
|
||||
}
|
||||
|
||||
if (key.downArrow || ch === 'j') {
|
||||
return update(grid, g => ({ ...g, activeRow: g.activeRow + 1 }))
|
||||
}
|
||||
|
||||
return grid
|
||||
},
|
||||
|
||||
render({ cols, state, t }) {
|
||||
return (
|
||||
<Overlay zone="center">
|
||||
<FloatBox color={t.color.border}>
|
||||
<GridTestOverlay cols={Math.max(1, Math.min(cols - 6, 120))} state={state} t={t} />
|
||||
</FloatBox>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
})
|
||||
24
ui-tui/src/sdk/apps/gridTestState.ts
Normal file
24
ui-tui/src/sdk/apps/gridTestState.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** State for the /grid-test reference app. Lives apart from the app
|
||||
* definition so the render components can import the type without a cycle. */
|
||||
|
||||
/** Number of live panels in the streams demo (focus wraps mod this). */
|
||||
export const GRID_STREAM_COUNT = 6
|
||||
|
||||
export interface GridTestState {
|
||||
activeCol: number
|
||||
activeRow: number
|
||||
/** Areas mode: fixed-height 2D grid with rowSpan/colSpan demo cells. */
|
||||
areas: boolean
|
||||
cols: number
|
||||
gap: null | number
|
||||
nested: boolean
|
||||
paddingX: null | number
|
||||
rows: number
|
||||
/** Streams mode: live-updating panels tiled by GridAreas. */
|
||||
streams: boolean
|
||||
/** Streams mode: which panel h/l focus is on (0-based, wraps). */
|
||||
streamFocus: number
|
||||
/** Streams mode: which panel owns the promoted 2x2 slot. */
|
||||
streamMain: number
|
||||
zoomed: boolean
|
||||
}
|
||||
13
ui-tui/src/sdk/apps/index.ts
Normal file
13
ui-tui/src/sdk/apps/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** Reference apps. Importing this module registers them (defineWidgetApp
|
||||
* runs at module load) — appLayout imports it once at startup. User widgets
|
||||
* from $HERMES_HOME/tui-widgets ride the same import (async, non-fatal). */
|
||||
import { loadUserWidgets, watchUserWidgets } from '../userWidgets.js'
|
||||
|
||||
void loadUserWidgets()
|
||||
watchUserWidgets()
|
||||
|
||||
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'
|
||||
92
ui-tui/src/sdk/apps/ticker.tsx
Normal file
92
ui-tui/src/sdk/apps/ticker.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Dialog } from '../../components/overlay.js'
|
||||
import { sparkline } from '../../lib/charts.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 POINTS = 26
|
||||
const TICK_MS = 250
|
||||
const PIP = 0.0001
|
||||
|
||||
export interface TickerState {
|
||||
symbol: string
|
||||
}
|
||||
|
||||
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}>{sparkline(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>
|
||||
)
|
||||
}
|
||||
})
|
||||
203
ui-tui/src/sdk/apps/weather.tsx
Normal file
203
ui-tui/src/sdk/apps/weather.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import { ShimmerRows } from '../../components/loaders.js'
|
||||
import { Dialog } from '../../components/overlay.js'
|
||||
import { mix } from '../../lib/color.js'
|
||||
import type { Theme } from '../../theme.js'
|
||||
import { updateWidget } from '../host.js'
|
||||
import { defineWidgetApp } from '../registry.js'
|
||||
import { isCtrl } from '../types.js'
|
||||
|
||||
/**
|
||||
* Weather — the data-backed reference app. Demonstrates the async contract:
|
||||
* `init` returns a loading state and fires the fetch; the resolution lands
|
||||
* through `updateWidget`, which no-ops if the app was closed meanwhile.
|
||||
* Everything visual derives from the theme (art tinted by family tones).
|
||||
*/
|
||||
|
||||
const USAGE = 'usage: /weather [location] (blank = geolocate by IP)'
|
||||
|
||||
// Skeleton mirrors the ready layout: art column + four stat lines.
|
||||
const LOADING_ROWS: readonly (readonly [number, number])[] = [
|
||||
[13, 12],
|
||||
[13, 16],
|
||||
[13, 14],
|
||||
[13, 11]
|
||||
]
|
||||
|
||||
type Phase = { kind: 'error'; message: string } | { kind: 'loading' } | { kind: 'ready'; report: Report }
|
||||
|
||||
export interface WeatherState {
|
||||
location: string
|
||||
phase: Phase
|
||||
}
|
||||
|
||||
interface Report {
|
||||
area: string
|
||||
condition: string
|
||||
feelsC: string
|
||||
humidity: string
|
||||
tempC: string
|
||||
weatherCode: number
|
||||
windKmph: string
|
||||
}
|
||||
|
||||
// WWO weather codes → art bucket. Table-driven; unknown codes read as cloud.
|
||||
type Art = 'cloud' | 'fog' | 'rain' | 'snow' | 'sun' | 'thunder'
|
||||
|
||||
const ART_BY_CODE: readonly [codes: readonly number[], art: Art][] = [
|
||||
[[113], 'sun'],
|
||||
[[116, 119, 122], 'cloud'],
|
||||
[[143, 248, 260], 'fog'],
|
||||
[[176, 263, 266, 293, 296, 299, 302, 305, 308, 353, 356, 359], 'rain'],
|
||||
[[179, 182, 185, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377], 'snow'],
|
||||
[[200, 386, 389, 392, 395], 'thunder']
|
||||
]
|
||||
|
||||
const artFor = (code: number): Art => ART_BY_CODE.find(([codes]) => codes.includes(code))?.[1] ?? 'cloud'
|
||||
|
||||
const ART: Record<Art, readonly string[]> = {
|
||||
sun: [' \\ / ', ' .-. ', ' ― ( ) ― ', " `-' ", ' / \\ '],
|
||||
cloud: [' ', ' .--. ', ' .-( ). ', ' (___.__)__) ', ' '],
|
||||
fog: [' ', ' _ - _ - _ - ', ' _ - _ - _ ', ' _ - _ - _ - ', ' '],
|
||||
rain: [' .-. ', ' ( ). ', ' (___(__) ', ' ‚ʻ‚ʻ‚ʻ‚ʻ ', ' ‚ʻ‚ʻ‚ʻ‚ʻ '],
|
||||
snow: [' .-. ', ' ( ). ', ' (___(__) ', ' * * * * ', ' * * * * '],
|
||||
thunder: [' .-. ', ' ( ). ', ' (___(__) ', ' ⚡‚ʻ⚡‚ʻ ', ' ‚ʻ⚡‚ʻ⚡ ']
|
||||
}
|
||||
|
||||
/** Art tint rides the theme family: sun in primary gold, rain in the shell
|
||||
* blue, fog in muted — never hardcoded hexes. */
|
||||
const artColor = (art: Art, t: Theme): string =>
|
||||
({
|
||||
cloud: t.color.muted,
|
||||
fog: t.color.muted,
|
||||
rain: t.color.shellDollar,
|
||||
snow: t.color.text,
|
||||
sun: t.color.primary,
|
||||
thunder: t.color.warn
|
||||
})[art]
|
||||
|
||||
async function fetchReport(location: string): Promise<Report> {
|
||||
const res = await fetch(`https://wttr.in/${encodeURIComponent(location)}?format=j1`, {
|
||||
headers: { 'User-Agent': 'hermes-tui-weather' },
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`wttr.in answered ${res.status}`)
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
current_condition?: {
|
||||
FeelsLikeC?: string
|
||||
humidity?: string
|
||||
temp_C?: string
|
||||
weatherCode?: string
|
||||
weatherDesc?: { value?: string }[]
|
||||
windspeedKmph?: string
|
||||
}[]
|
||||
nearest_area?: { areaName?: { value?: string }[]; country?: { value?: string }[] }[]
|
||||
}
|
||||
|
||||
const now = data.current_condition?.[0]
|
||||
const area = data.nearest_area?.[0]
|
||||
|
||||
if (!now) {
|
||||
throw new Error('no current conditions in reply')
|
||||
}
|
||||
|
||||
return {
|
||||
area: [area?.areaName?.[0]?.value, area?.country?.[0]?.value].filter(Boolean).join(', ') || location || 'here',
|
||||
condition: now.weatherDesc?.[0]?.value ?? 'unknown',
|
||||
feelsC: now.FeelsLikeC ?? '?',
|
||||
humidity: now.humidity ?? '?',
|
||||
tempC: now.temp_C ?? '?',
|
||||
weatherCode: Number(now.weatherCode ?? 116),
|
||||
windKmph: now.windspeedKmph ?? '?'
|
||||
}
|
||||
}
|
||||
|
||||
function load(location: string): void {
|
||||
fetchReport(location).then(
|
||||
report => updateWidget(weatherApp, state => ({ ...state, phase: { kind: 'ready', report } as Phase })),
|
||||
(error: unknown) =>
|
||||
updateWidget(weatherApp, state => ({
|
||||
...state,
|
||||
phase: { kind: 'error', message: error instanceof Error ? error.message : String(error) } as Phase
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export const weatherApp = defineWidgetApp<WeatherState>({
|
||||
id: 'weather',
|
||||
help: 'current conditions with themed ASCII art (wttr.in)',
|
||||
mode: 'ambient',
|
||||
usage: USAGE,
|
||||
|
||||
init(arg) {
|
||||
const location = arg.trim()
|
||||
|
||||
load(location)
|
||||
|
||||
return { location, phase: { kind: 'loading' } }
|
||||
},
|
||||
|
||||
reduce(state, { ch, key }) {
|
||||
if (key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ch === 'r') {
|
||||
load(state.location)
|
||||
|
||||
return { ...state, phase: { kind: 'loading' } }
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog title={title} width={Math.min(42, cols - 4)}>
|
||||
{phase.kind === 'loading' && (
|
||||
<ShimmerRows
|
||||
color={mix(t.color.muted, t.color.completionBg, 0.5)}
|
||||
highlight={t.color.label}
|
||||
rows={LOADING_ROWS}
|
||||
/>
|
||||
)}
|
||||
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
|
||||
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ReadyBody({ report, t }: { report: Report; t: Theme }) {
|
||||
const art = artFor(report.weatherCode)
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{ART[art].map((line, i) => (
|
||||
<Text color={artColor(art, t)} key={i}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color={t.color.label}>{report.condition}</Text>
|
||||
<Text color={t.color.text}>
|
||||
{report.tempC}°C <Text color={t.color.muted}>(feels {report.feelsC}°C)</Text>
|
||||
</Text>
|
||||
<Text color={t.color.muted}>wind {report.windKmph} km/h</Text>
|
||||
<Text color={t.color.muted}>humidity {report.humidity}%</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
286
ui-tui/src/sdk/host.tsx
Normal file
286
ui-tui/src/sdk/host.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
import { Box, Text, useStdout } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/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, AmbientZone, WidgetApp, WidgetInput } from './types.js'
|
||||
|
||||
/**
|
||||
* 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 surfaces (dock rows + side rails,
|
||||
* all reserving real space). Everything else — state shape, keybindings,
|
||||
* presentation — belongs to the app.
|
||||
*/
|
||||
|
||||
// ── placement ────────────────────────────────────────────────────────
|
||||
|
||||
const isAmbient = (app: WidgetApp<never>) => app.mode === 'ambient'
|
||||
|
||||
const zoneOf = (active: ActiveWidget): AmbientZone => getWidgetApp(active.appId)?.zone ?? 'dock-bottom'
|
||||
|
||||
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<never>, 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 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)
|
||||
|
||||
if (!app) {
|
||||
return `unknown widget app: ${id}`
|
||||
}
|
||||
|
||||
if (isAmbient(app)) {
|
||||
const ambient = $overlayState.get().ambient
|
||||
|
||||
if (ambient.some(active => active.appId === id) && !arg.trim()) {
|
||||
patchOverlayState({ ambient: withoutApp(ambient, id) })
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const state = app.init(arg)
|
||||
|
||||
if (state === null) {
|
||||
return app.usage ?? `usage: /${id}`
|
||||
}
|
||||
|
||||
place(app, 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 modal app). */
|
||||
export const openWidget = <S,>(app: WidgetApp<S>, state: S): void => place(app as WidgetApp<never>, 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<S>(app: WidgetApp<S>, fn: (state: S) => S): void {
|
||||
const overlay = $overlayState.get()
|
||||
|
||||
if (isAmbient(app as WidgetApp<never>)) {
|
||||
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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (overlay.widget?.appId === app.id) {
|
||||
patchOverlayState({ widget: { appId: app.id, state: fn(overlay.widget.state as S) } })
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
|
||||
const app = getWidgetApp(active.appId)
|
||||
|
||||
if (!app) {
|
||||
closeWidget()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const next = app.reduce(active.state as never, input)
|
||||
|
||||
if (next === null) {
|
||||
closeWidget()
|
||||
} else if (next !== active.state) {
|
||||
patchOverlayState({ widget: { appId: active.appId, state: next } })
|
||||
}
|
||||
|
||||
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
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return (
|
||||
<WidgetBoundary
|
||||
appId={active.appId}
|
||||
errorColor={(ctx.t as { color: { error: string } }).color.error}
|
||||
key={active.appId}
|
||||
>
|
||||
{app.render({ ...ctx, state: active.state as never })}
|
||||
</WidgetBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const CardStack = ({ apps, ctx }: { apps: ActiveWidget[]; ctx: RenderCtx }) => (
|
||||
<Box flexDirection="column" rowGap={1}>
|
||||
{apps.map(active => (
|
||||
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
/** 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 ctx = useRenderCtx()
|
||||
|
||||
return overlay.widget ? renderApp(overlay.widget, ctx) : null
|
||||
}
|
||||
|
||||
/** 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 = useRenderCtx()
|
||||
const docked = overlay.ambient.filter(active => zoneOf(active) === placement)
|
||||
|
||||
if (!docked.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 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%">
|
||||
{docked.map(active => (
|
||||
<Box key={active.appId}>{renderApp(active, ctx)}</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── rails ────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_RAIL_WIDTH = 44
|
||||
|
||||
const railSide = (zone: AmbientZone): 'left' | '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)
|
||||
|
||||
/** 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 {
|
||||
return ambientRailWidth(side, useStore($overlayState).ambient)
|
||||
}
|
||||
|
||||
/** A side rail: a RESERVED column beside the transcript holding corner
|
||||
* 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 = useRenderCtx()
|
||||
const apps = railApps(overlay.ambient, side)
|
||||
|
||||
if (!apps.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
justifyContent="space-between"
|
||||
paddingX={1}
|
||||
width={ambientRailWidth(side, overlay.ambient)}
|
||||
>
|
||||
<CardStack apps={apps.filter(active => zoneOf(active).startsWith('top'))} ctx={ctx} />
|
||||
<CardStack apps={apps.filter(active => zoneOf(active).startsWith('bottom'))} ctx={ctx} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
69
ui-tui/src/sdk/index.ts
Normal file
69
ui-tui/src/sdk/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* The TUI widget SDK — the one import surface a widget app needs.
|
||||
*
|
||||
* An app is a `WidgetApp` (state + reducer + render) registered with
|
||||
* `defineWidgetApp` and launched by id (usually from a slash command via
|
||||
* `launchWidget`). While active it owns every keypress and renders in a
|
||||
* viewport-level slot, composing the same layout/theme primitives every
|
||||
* built-in surface uses — so apps inherit grid tracks, zoned overlays,
|
||||
* selection chips, and skin-derived color by construction.
|
||||
*
|
||||
* See `sdk/apps/` for the reference apps (`/grid-test`, `/dialog-test`).
|
||||
*/
|
||||
|
||||
// Theme + chrome primitives
|
||||
export { Accordion } from '../components/accordion.js'
|
||||
export { Shimmer, ShimmerRows, shimmerSegments, useShimmerPhase } from '../components/loaders.js'
|
||||
// Layout components + overlay primitives
|
||||
export { Dialog, Overlay, type OverlayZone } from '../components/overlay.js'
|
||||
export { OverlayHint, windowItems } from '../components/overlayControls.js'
|
||||
export {
|
||||
ActionRow,
|
||||
chipRowProps,
|
||||
listRowStyle,
|
||||
MenuRow,
|
||||
scrollbarColors,
|
||||
useMenu
|
||||
} from '../components/overlayPrimitives.js'
|
||||
|
||||
export { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
|
||||
|
||||
export { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
|
||||
export { contrastRatio, liftForContrast, mix, relativeLuminance } from '../lib/color.js'
|
||||
// Layout engine
|
||||
export {
|
||||
type GridAreaItem,
|
||||
type GridAreasLayout,
|
||||
type GridAreasOptions,
|
||||
type GridTrackSize,
|
||||
layoutGridAreas,
|
||||
layoutWidgetGrid,
|
||||
resolveGridTracks,
|
||||
type WidgetGridItem,
|
||||
type WidgetGridLayout,
|
||||
type WidgetGridLayoutOptions
|
||||
} from '../lib/widgetGrid.js'
|
||||
|
||||
export type { Theme, ThemeColors } from '../theme.js'
|
||||
// App contract + host
|
||||
export {
|
||||
ActiveWidgetSlot,
|
||||
AmbientDock,
|
||||
AmbientRail,
|
||||
ambientRailWidth,
|
||||
closeWidget,
|
||||
dispatchWidgetInput,
|
||||
launchWidget,
|
||||
openWidget,
|
||||
updateWidget
|
||||
} from './host.js'
|
||||
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.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'
|
||||
20
ui-tui/src/sdk/registry.ts
Normal file
20
ui-tui/src/sdk/registry.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { WidgetApp } from './types.js'
|
||||
|
||||
const apps = new Map<string, WidgetApp<never>>()
|
||||
|
||||
/** Identity helper that pins the state type, then registers. Last writer
|
||||
* wins so a user/plugin app can shadow a built-in of the same id. */
|
||||
export function defineWidgetApp<S>(app: WidgetApp<S>): WidgetApp<S> {
|
||||
apps.set(app.id, app as WidgetApp<never>)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export const getWidgetApp = (id: string): undefined | WidgetApp<never> => apps.get(id)
|
||||
|
||||
/** Unregister (user-widget file deleted). Built-ins never call this. */
|
||||
export const removeWidgetApp = (id: string): boolean => apps.delete(id)
|
||||
|
||||
/** 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))
|
||||
83
ui-tui/src/sdk/types.ts
Normal file
83
ui-tui/src/sdk/types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Key } from '@hermes/ink'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
/** One keypress, as the input pipeline delivers it. */
|
||||
export interface WidgetInput {
|
||||
ch: string
|
||||
key: Key
|
||||
}
|
||||
|
||||
export interface WidgetRenderCtx<S> {
|
||||
/** Terminal columns available to the app. */
|
||||
cols: number
|
||||
/** Terminal rows available to the app. */
|
||||
rows: number
|
||||
state: S
|
||||
t: Theme
|
||||
}
|
||||
|
||||
/**
|
||||
* A widget app: a self-contained overlay surface with its own state, input
|
||||
* reducer, and render — the TUI equivalent of a desktop panel. The host owns
|
||||
* exactly one active app at a time; while active, the app receives every
|
||||
* keypress and the composer is blocked.
|
||||
*
|
||||
* Contract:
|
||||
* - `init(arg)` parses the launch argument (slash-command tail) into initial
|
||||
* state; `null` refuses the launch and the launcher prints `usage`.
|
||||
* - `reduce(state, input)` returns the next state, the SAME reference to
|
||||
* swallow the key unchanged, or `null` to close the app.
|
||||
* - `render(ctx)` returns the overlay node. Compose with the SDK primitives
|
||||
* (`Overlay`, `Dialog`, `WidgetGrid`, `GridAreas`, `chipRowProps`, …) so
|
||||
* placement and theming stay engine-derived.
|
||||
*/
|
||||
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'
|
||||
/** 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
|
||||
state: unknown
|
||||
}
|
||||
|
||||
/** Ctrl+<letter> test, shared so app reducers match the core pipeline. */
|
||||
export const isCtrl = (key: { ctrl: boolean }, ch: string, target: string): boolean =>
|
||||
key.ctrl && ch.toLowerCase() === target
|
||||
213
ui-tui/src/sdk/userWidgets.ts
Normal file
213
ui-tui/src/sdk/userWidgets.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { watch } from 'fs'
|
||||
import { readdir } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
|
||||
import { Box, Text } from '@hermes/ink'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Accordion } from '../components/accordion.js'
|
||||
import { Shimmer, ShimmerRows, useShimmerPhase } from '../components/loaders.js'
|
||||
import { Dialog, Overlay } from '../components/overlay.js'
|
||||
import { GridAreas, WidgetGrid } from '../components/widgetGrid.js'
|
||||
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
|
||||
import { recordParentLifecycle } from '../lib/parentLog.js'
|
||||
|
||||
import { openWidget, updateWidget } from './host.js'
|
||||
import { defineWidgetApp, listWidgetApps, removeWidgetApp } from './registry.js'
|
||||
import { isCtrl } from './types.js'
|
||||
|
||||
/**
|
||||
* User widget apps — Hermes authors its own TUI widgets, mirroring the
|
||||
* Python plugin contract: drop `<name>.mjs` into `$HERMES_HOME/tui-widgets/`,
|
||||
* default-export `register(sdk)`, and the app surfaces in `/` completions
|
||||
* and dispatch automatically (the registry is the catalog). Plain ESM so the
|
||||
* production bundle can import it — no bundler, no JSX; `sdk.h` is
|
||||
* React.createElement.
|
||||
*
|
||||
* Trust model matches `~/.hermes/plugins/`: files under HERMES_HOME execute
|
||||
* with the TUI's privileges. Load errors log and skip — a broken widget
|
||||
* never takes the TUI down.
|
||||
*/
|
||||
|
||||
/** Everything a user widget may touch, passed INTO its register() — user
|
||||
* files have no resolvable import path to the bundle. */
|
||||
export const widgetSdk = {
|
||||
Accordion,
|
||||
Box,
|
||||
Dialog,
|
||||
GridAreas,
|
||||
Overlay,
|
||||
React,
|
||||
Shimmer,
|
||||
ShimmerRows,
|
||||
Text,
|
||||
WidgetGrid,
|
||||
defineWidgetApp,
|
||||
gauge,
|
||||
h: React.createElement,
|
||||
hbars,
|
||||
isCtrl,
|
||||
openWidget,
|
||||
sparkRows,
|
||||
sparkline,
|
||||
updateWidget,
|
||||
useShimmerPhase
|
||||
} as const
|
||||
|
||||
export type WidgetSdk = typeof widgetSdk
|
||||
|
||||
const widgetsDir = () => join(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes'), 'tui-widgets')
|
||||
|
||||
export interface UserWidgetLoadResult {
|
||||
/** App ids newly registered by this scan. */
|
||||
added: string[]
|
||||
errors: { file: string; message: string }[]
|
||||
loaded: string[]
|
||||
/** App ids unregistered because their file disappeared. */
|
||||
removed: string[]
|
||||
}
|
||||
|
||||
/** Which app ids each user file registered — the delete-sync source of
|
||||
* truth (file gone on the next scan ⇒ its apps unregister). */
|
||||
const fileApps = new Map<string, string[]>()
|
||||
|
||||
const listeners = new Set<(result: UserWidgetLoadResult) => void>()
|
||||
|
||||
/** Subscribe to scan results — the app layer announces loads in the
|
||||
* transcript so a hot-loaded widget is VISIBLY live (silent success is
|
||||
* indistinguishable from failure). */
|
||||
export function onUserWidgets(listener: (result: UserWidgetLoadResult) => void): () => void {
|
||||
listeners.add(listener)
|
||||
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Scan + import + register, diffing the registry per file. Cache-busted so
|
||||
* edits reload without restarting the TUI (last-writer-wins shadows stale
|
||||
* definitions). Files that vanished unregister their apps. */
|
||||
export async function loadUserWidgets(dir = widgetsDir()): Promise<UserWidgetLoadResult> {
|
||||
const result: UserWidgetLoadResult = { added: [], errors: [], loaded: [], removed: [] }
|
||||
|
||||
let files: string[] = []
|
||||
|
||||
try {
|
||||
files = (await readdir(dir)).filter(f => f.endsWith('.mjs')).sort()
|
||||
} catch {
|
||||
// No directory: fall through so previously-loaded files still delete-sync.
|
||||
}
|
||||
|
||||
for (const [file, ids] of fileApps) {
|
||||
if (!files.includes(file)) {
|
||||
fileApps.delete(file)
|
||||
|
||||
for (const id of ids) {
|
||||
if (removeWidgetApp(id)) {
|
||||
result.removed.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const before = new Set(listWidgetApps().map(app => app.id))
|
||||
|
||||
try {
|
||||
const mod = (await import(`${pathToFileURL(join(dir, file)).href}?t=${Date.now()}`)) as {
|
||||
default?: (sdk: WidgetSdk) => void
|
||||
}
|
||||
|
||||
if (typeof mod.default !== 'function') {
|
||||
throw new Error('default export must be register(sdk)')
|
||||
}
|
||||
|
||||
mod.default(widgetSdk)
|
||||
result.loaded.push(file)
|
||||
|
||||
const ids = listWidgetApps()
|
||||
.map(app => app.id)
|
||||
.filter(id => !before.has(id))
|
||||
|
||||
// Re-registrations of existing ids keep their prior file attribution.
|
||||
if (ids.length) {
|
||||
fileApps.set(file, ids)
|
||||
result.added.push(...ids)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
result.errors.push({ file, message })
|
||||
recordParentLifecycle(`user widget ${file} failed to load: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added.length) {
|
||||
recordParentLifecycle(`user widgets registered: ${result.added.join(', ')}`)
|
||||
}
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let watching = false
|
||||
|
||||
/** Generative-UI hot loading: watch the widgets directory and re-scan on
|
||||
* every change, so a widget Hermes writes appears within ~a second — no
|
||||
* `/widgets-reload`, no restart (GUI parity). Debounced (editors and
|
||||
* write_file emit bursts); polls until the directory exists so the very
|
||||
* first widget ever written also hot-loads. */
|
||||
export function watchUserWidgets(dir = widgetsDir()): void {
|
||||
if (watching) {
|
||||
return
|
||||
}
|
||||
|
||||
watching = true
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
const attach = () => {
|
||||
try {
|
||||
const watcher = watch(dir, () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => void loadUserWidgets(dir), 300)
|
||||
timer.unref?.()
|
||||
})
|
||||
|
||||
watcher.unref?.()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false // directory doesn't exist yet
|
||||
}
|
||||
}
|
||||
|
||||
if (!attach()) {
|
||||
// Event-driven first-creation: watch the PARENT for the widgets dir to
|
||||
// appear, attach + scan the instant it does. The very first widget a
|
||||
// user (or Hermes) ever writes must hot-load too — a 10s poll here read
|
||||
// as "requires a restart" in live use.
|
||||
try {
|
||||
const parent = watch(dirname(dir), () => {
|
||||
if (attach()) {
|
||||
parent.close()
|
||||
void loadUserWidgets(dir)
|
||||
}
|
||||
})
|
||||
|
||||
parent.unref?.()
|
||||
} catch {
|
||||
const poll = setInterval(() => {
|
||||
if (attach()) {
|
||||
clearInterval(poll)
|
||||
void loadUserWidgets(dir)
|
||||
}
|
||||
}, 2_000)
|
||||
|
||||
poll.unref?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue