From fc3be32a9a9774a871d71b317375f41002853f91 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 20:43:28 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui-tui):=20weather=20reference=20app=20?= =?UTF-8?q?=E2=80=94=20the=20async-data=20contract,=20themed=20ASCII=20art?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- ui-tui/src/__tests__/weatherApp.test.ts | 91 ++++++++++++ ui-tui/src/app/slash/commands/debug.ts | 1 + ui-tui/src/sdk/apps/dialogTest.tsx | 4 +- ui-tui/src/sdk/apps/index.ts | 1 + ui-tui/src/sdk/apps/weather.tsx | 185 ++++++++++++++++++++++++ ui-tui/src/sdk/host.tsx | 14 ++ ui-tui/src/sdk/index.ts | 2 +- 7 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 ui-tui/src/__tests__/weatherApp.test.ts create mode 100644 ui-tui/src/sdk/apps/weather.tsx diff --git a/ui-tui/src/__tests__/weatherApp.test.ts b/ui-tui/src/__tests__/weatherApp.test.ts new file mode 100644 index 000000000000..91f6fae17358 --- /dev/null +++ b/ui-tui/src/__tests__/weatherApp.test.ts @@ -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 = {}, 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() + }) +}) diff --git a/ui-tui/src/app/slash/commands/debug.ts b/ui-tui/src/app/slash/commands/debug.ts index a82ae840bf4a..ea98b55e546c 100644 --- a/ui-tui/src/app/slash/commands/debug.ts +++ b/ui-tui/src/app/slash/commands/debug.ts @@ -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)', diff --git a/ui-tui/src/sdk/apps/dialogTest.tsx b/ui-tui/src/sdk/apps/dialogTest.tsx index 61ca348e5c33..896a62c2dce5 100644 --- a/ui-tui/src/sdk/apps/dialogTest.tsx +++ b/ui-tui/src/sdk/apps/dialogTest.tsx @@ -51,9 +51,7 @@ export const dialogTestApp = defineWidgetApp({ return key.escape || key.return || ch === 'q' || isCtrl(key, ch, 'c') ? null : state }, - render({ cols, state, t }) { - void t - + render({ cols, state }) { return ( diff --git a/ui-tui/src/sdk/apps/index.ts b/ui-tui/src/sdk/apps/index.ts index ee4f6961de1d..3a39a104ac78 100644 --- a/ui-tui/src/sdk/apps/index.ts +++ b/ui-tui/src/sdk/apps/index.ts @@ -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' diff --git a/ui-tui/src/sdk/apps/weather.tsx b/ui-tui/src/sdk/apps/weather.tsx new file mode 100644 index 000000000000..8f01a259dce5 --- /dev/null +++ b/ui-tui/src/sdk/apps/weather.tsx @@ -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 = { + 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 { + 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({ + 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 ( + + + {phase.kind === 'loading' && fetching wttr.in…} + {phase.kind === 'error' && {phase.message}} + {phase.kind === 'ready' && } + + + ) + } +}) + +function ReadyBody({ report, t }: { report: Report; t: Theme }) { + const art = artFor(report.weatherCode) + + return ( + + + {ART[art].map((line, i) => ( + + {line} + + ))} + + + {report.condition} + + {report.tempC}°C (feels {report.feelsC}°C) + + wind {report.windKmph} km/h + humidity {report.humidity}% + + + ) +} diff --git a/ui-tui/src/sdk/host.tsx b/ui-tui/src/sdk/host.tsx index dfbaa116b5b2..c851e58e18c7 100644 --- a/ui-tui/src/sdk/host.tsx +++ b/ui-tui/src/sdk/host.tsx @@ -43,6 +43,20 @@ export function openWidget(app: WidgetApp, 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(app: WidgetApp, 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 { diff --git a/ui-tui/src/sdk/index.ts b/ui-tui/src/sdk/index.ts index 3eba293c72d5..1c2336cfc3c3 100644 --- a/ui-tui/src/sdk/index.ts +++ b/ui-tui/src/sdk/index.ts @@ -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'