mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
feat(ui-tui): weather reference app — the async-data contract, themed ASCII art
/weather [location]: wttr.in current conditions behind a Dialog, art bucket table-driven off WWO weather codes, every tint a theme family tone (sun = primary, rain = shell blue, thunder = warn). Proves the async story the demos don't: init returns a loading phase and fires the fetch; results land through the new host.updateWidget, which patches state ONLY while the app is still active — a late resolution can never resurrect a closed app or clobber a different one. `r` refetches; Esc/q/Enter close. Four async-contract tests (loading→ready via updateWidget, late-resolution guard, error phase, keymap). 1253 TS tests green.
This commit is contained in:
parent
d8fcab4736
commit
fc3be32a9a
7 changed files with 294 additions and 4 deletions
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().widget?.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')
|
||||
|
||||
// Close while in flight, then resolve.
|
||||
expect(weatherApp.reduce(activeState()!, key({ escape: true }))).toBeNull()
|
||||
resetOverlayState()
|
||||
resolve({ json: async () => wttrReply('113'), ok: true })
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
|
||||
expect(getOverlayState().widget).toBeNull()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -26,6 +26,7 @@ const widgetCommand = (name: string, help: string): SlashCommand => ({
|
|||
export const debugCommands: SlashCommand[] = [
|
||||
widgetCommand('grid-test', 'open an interactive widget-grid demo overlay'),
|
||||
widgetCommand('dialog-test', 'open a sample dialog overlay with a faked backdrop'),
|
||||
widgetCommand('weather', 'current conditions with themed ASCII art (wttr.in)'),
|
||||
|
||||
{
|
||||
help: 'write a V8 heap snapshot + memory diagnostics (see HERMES_HEAPDUMP_DIR)',
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ export const dialogTestApp = defineWidgetApp<DialogTestState>({
|
|||
return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state
|
||||
},
|
||||
|
||||
render({ cols, state, t }) {
|
||||
void t
|
||||
|
||||
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)}>
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@
|
|||
export { dialogTestApp } from './dialogTest.js'
|
||||
export { gridTestApp } from './gridTest.js'
|
||||
export { GRID_STREAM_COUNT, type GridTestState } from './gridTestState.js'
|
||||
export { weatherApp, type WeatherState } from './weather.js'
|
||||
|
|
|
|||
185
ui-tui/src/sdk/apps/weather.tsx
Normal file
185
ui-tui/src/sdk/apps/weather.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { Box, Text } from '@hermes/ink'
|
||||
|
||||
import { Dialog, Overlay } from '../../components/overlay.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)'
|
||||
|
||||
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',
|
||||
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
|
||||
},
|
||||
|
||||
render({ cols, state, t }) {
|
||||
const { phase } = state
|
||||
const title = phase.kind === 'ready' ? phase.report.area : 'Weather'
|
||||
|
||||
return (
|
||||
<Overlay backdrop zone="center">
|
||||
<Dialog hint="r refresh · Esc/q close" title={title} width={Math.min(46, cols - 8)}>
|
||||
{phase.kind === 'loading' && <Text color={t.color.muted}>fetching wttr.in…</Text>}
|
||||
{phase.kind === 'error' && <Text color={t.color.error}>{phase.message}</Text>}
|
||||
{phase.kind === 'ready' && <ReadyBody report={phase.report} t={t} />}
|
||||
</Dialog>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,6 +43,20 @@ export function openWidget<S>(app: WidgetApp<S>, state: S): void {
|
|||
patchOverlayState({ widget: { appId: app.id, state } })
|
||||
}
|
||||
|
||||
/** Async state delivery: patch the app's state ONLY while it is still the
|
||||
* active widget — a late fetch resolution can never resurrect a closed app
|
||||
* or clobber a different one. This is how data-backed apps land results
|
||||
* outside the input pipeline (see the weather reference app). */
|
||||
export function updateWidget<S>(app: WidgetApp<S>, fn: (state: S) => S): void {
|
||||
const active = $overlayState.get().widget
|
||||
|
||||
if (active?.appId !== app.id) {
|
||||
return
|
||||
}
|
||||
|
||||
patchOverlayState({ widget: { appId: app.id, state: fn(active.state as S) } })
|
||||
}
|
||||
|
||||
/** Feed one keypress to the active app. Returns true when a widget is active
|
||||
* (apps swallow every key while open — the overlay is modal). */
|
||||
export function dispatchWidgetInput(input: WidgetInput): boolean {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,6 @@ export {
|
|||
|
||||
export type { Theme, ThemeColors } from '../theme.js'
|
||||
// App contract + host
|
||||
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget } from './host.js'
|
||||
export { ActiveWidgetSlot, closeWidget, dispatchWidgetInput, launchWidget, openWidget, updateWidget } from './host.js'
|
||||
export { defineWidgetApp, getWidgetApp, listWidgetApps } from './registry.js'
|
||||
export { type ActiveWidget, isCtrl, type WidgetApp, type WidgetInput, type WidgetRenderCtx } from './types.js'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue