Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+66
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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?.()
}
}
}