diff --git a/ui-tui-opentui-v2/src/boundary/gateway/client.ts b/ui-tui-opentui-v2/src/boundary/gateway/client.ts new file mode 100644 index 00000000000..eeff84f38aa --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/gateway/client.ts @@ -0,0 +1,228 @@ +/** + * Low-level JSON-RPC-over-stdio client for the Python `tui_gateway` (spec v4 §4). + * Re-authored minimal (NOT the Ink client's 740-LOC attach-mode/buffering) but + * the WIRE CONTRACT is identical (verified against ui-tui/src/gatewayClient.ts + + * tui_gateway/server.py + entry.py + transport.py): + * + * - spawn: `python -m tui_gateway.entry`, cwd=srcRoot, env={...process.env, + * PYTHONPATH=srcRoot:…, HERMES_PYTHON_SRC_ROOT=srcRoot}, stdio piped. + * - framing: newline-delimited compact JSON, BOTH directions, on ONE stdout. + * - request: {id:"r", jsonrpc:"2.0", method, params} + "\n". + * - response: {jsonrpc, id, result} | {jsonrpc, id, error:{code,message}} — match by id. + * - event: {jsonrpc, method:"event", params:{type, session_id?, payload?}} (NO id). + * - handshake: child emits {event, params:{type:"gateway.ready", payload:{skin}}} + * UNSOLICITED first; no subscribe RPC. Then client drives session.create / + * session.resume / prompt.submit / *.respond. + * - GOTCHA: session.resume/prompt.submit/slash.exec are LONG handlers — their + * {id,result} arrives async, interleaved with events. Keep the pending map + * authoritative; never assume in-order response delivery. + * + * Raw events are surfaced as `unknown` (the params object). The liveGateway + * layer Schema-decodes them once at the boundary (spec v4 §3.3); this client + * stays decode-agnostic so the transport and the schema evolve independently. + */ +import type { Log } from '../log.ts' +import { resolvePython, resolveSrcRoot } from './python.ts' + +interface Pending { + resolve: (result: unknown) => void + reject: (error: Error) => void + method: string +} + +export interface RawClientOptions { + readonly log: Log + /** Called with each server-pushed event's `params` object (still unknown — decoded upstream). */ + readonly onEvent: (params: unknown) => void + /** Called when the child exits / errors (so the layer can reject pending + reconnect). */ + readonly onExit?: (reason: string) => void +} + +const REQUEST_TIMEOUT_MS = 120_000 + +export class RawGatewayClient { + private proc: ReturnType | null = null + private pending = new Map() + private reqId = 0 + private stdinBuffer = '' + private readonly log: Log + private readonly onEvent: (params: unknown) => void + private readonly onExit?: (reason: string) => void + + constructor(options: RawClientOptions) { + this.log = options.log + this.onEvent = options.onEvent + if (options.onExit) this.onExit = options.onExit + } + + /** Spawn the gateway child and begin reading frames. Idempotent. */ + start(): void { + if (this.proc) return + const srcRoot = resolveSrcRoot() + const python = resolvePython(srcRoot) + const cwd = process.env.HERMES_CWD?.trim() || srcRoot + const env: Record = { ...(process.env as Record) } + env.PYTHONPATH = env.PYTHONPATH ? `${srcRoot}:${env.PYTHONPATH}` : srcRoot + env.HERMES_PYTHON_SRC_ROOT = srcRoot + + this.log.info('gateway', 'spawning tui_gateway', { python, cwd, srcRoot }) + + const proc = Bun.spawn([python, '-m', 'tui_gateway.entry'], { + cwd, + env, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + onExit: (_p, code, signal) => { + const reason = `gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})` + this.log.warn('gateway', reason) + this.rejectAll(reason) + this.proc = null + this.onExit?.(reason) + } + }) + this.proc = proc + void this.readStdout(proc) + void this.readStderr(proc) + } + + private async readStdout(proc: ReturnType): Promise { + const stream = proc.stdout + if (!(stream instanceof ReadableStream)) return + const reader = stream.getReader() + const decoder = new TextDecoder() + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + this.stdinBuffer += decoder.decode(value, { stream: true }) + let nl: number + while ((nl = this.stdinBuffer.indexOf('\n')) >= 0) { + const line = this.stdinBuffer.slice(0, nl) + this.stdinBuffer = this.stdinBuffer.slice(nl + 1) + if (line.trim()) this.dispatch(line) + } + } + } catch (cause) { + this.log.error('gateway', 'stdout read loop failed', { cause: String(cause) }) + } + } + + private async readStderr(proc: ReturnType): Promise { + const stream = proc.stderr + if (!(stream instanceof ReadableStream)) return + const reader = stream.getReader() + const decoder = new TextDecoder() + let buf = '' + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let nl: number + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl) + buf = buf.slice(nl + 1) + if (line.trim()) { + this.log.debug('gateway.stderr', line) + // Surface as a synthetic gateway.stderr event (matches Ink). + this.onEvent({ type: 'gateway.stderr', payload: { line } }) + } + } + } + } catch { + // stderr pipe closing on exit is expected; ignore. + } + } + + private dispatch(line: string): void { + let msg: unknown + try { + msg = JSON.parse(line) + } catch { + this.log.warn('gateway', 'unparseable frame', { preview: line.slice(0, 120) }) + this.onEvent({ type: 'gateway.protocol_error', payload: { preview: line.slice(0, 120) } }) + return + } + if (!msg || typeof msg !== 'object') return + const frame = msg as { id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: unknown } + + // Response: has an id matching a pending request. + if (typeof frame.id === 'string' && this.pending.has(frame.id)) { + const p = this.pending.get(frame.id)! + this.pending.delete(frame.id) + if (frame.error) { + const err = frame.error as { code?: number; message?: string } + p.reject(new Error(err.message ?? `rpc error (${err.code ?? '?'})`)) + } else { + p.resolve(frame.result) + } + return + } + + // Event push: method === "event", no id. Surface params (decoded upstream). + if (frame.method === 'event' && frame.params && typeof frame.params === 'object') { + this.onEvent(frame.params) + return + } + + this.log.warn('gateway', 'unroutable frame', { preview: line.slice(0, 120) }) + } + + /** Send a JSON-RPC request; resolves with `result` (long handlers reply async). */ + request(method: string, params: unknown): Promise { + if (!this.proc) this.start() + const proc = this.proc + const stdin = proc?.stdin + if (!stdin || typeof stdin === 'number') return Promise.reject(new Error('gateway not running')) + + const id = `r${++this.reqId}` + const frame = JSON.stringify({ id, jsonrpc: '2.0', method, params: params ?? {} }) + '\n' + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`)) + }, REQUEST_TIMEOUT_MS) + + this.pending.set(id, { + method, + resolve: result => { + clearTimeout(timer) + resolve(result as A) + }, + reject: error => { + clearTimeout(timer) + reject(error) + } + }) + + try { + stdin.write(frame) + stdin.flush() + } catch (cause) { + this.pending.delete(id) + clearTimeout(timer) + reject(cause instanceof Error ? cause : new Error(String(cause))) + } + }) + } + + private rejectAll(reason: string): void { + for (const p of this.pending.values()) p.reject(new Error(reason)) + this.pending.clear() + } + + /** Close stdin (EOF → child exits) and stop. */ + stop(): void { + this.rejectAll('gateway stopping') + const stdin = this.proc?.stdin + if (stdin && typeof stdin !== 'number') { + try { + stdin.end() + } catch { + // already gone + } + } + this.proc = null + } +} diff --git a/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts b/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts new file mode 100644 index 00000000000..d0f3ab61c9f --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts @@ -0,0 +1,129 @@ +/** + * liveGateway — the GatewayService layer backed by the real Python `tui_gateway` + * (spec v4 §2/§3.2). Adapts RawGatewayClient to GatewayServiceShape: + * - decodes each raw event ONCE with the GatewayEvent Schema + * (decodeUnknownOption → unrecognized/malformed events skipped, never crash), + * - coalesces decoded events on a 16ms debounce flushed inside Solid `batch()` + * so a burst of deltas is ONE repaint (opencode sdk.tsx:54-80), + * - tracks the session id (set from session.create/resume result) for + * approval.respond {session_id}, + * - maps request failures to a typed GatewayError (never throws). + * + * The 16ms batch + `batch()` call is the boundary handing decoded events to + * Solid — one of the two approved Effect<->Solid contact points (spec v4 §1). + */ +import { Effect, Layer, Option, Schema } from 'effect' +import { batch } from 'solid-js' + +import { GatewayError } from '../errors.ts' +import { getLog } from '../log.ts' +import { GatewayEventSchema, type GatewayEvent } from '../schema/GatewayEvent.ts' +import { GatewayService, type GatewayServiceShape } from './GatewayService.ts' +import { RawGatewayClient } from './client.ts' + +const COALESCE_MS = 16 + +const decodeEvent = Schema.decodeUnknownOption(GatewayEventSchema) + +function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } { + const log = getLog() + const handlers = new Set<(event: GatewayEvent) => void>() + let sessionId: string | undefined + + // 16ms event coalescing → one batched repaint (opencode sdk.tsx model). + let queue: GatewayEvent[] = [] + let timer: ReturnType | undefined + let last = 0 + + const flush = () => { + timer = undefined + if (queue.length === 0) return + const events = queue + queue = [] + last = Date.now() + batch(() => { + for (const event of events) { + for (const handler of handlers) handler(event) + } + }) + } + + const enqueue = (event: GatewayEvent) => { + queue.push(event) + if (timer) return + // If we flushed recently (<16ms ago) batch with near-future events; else flush now. + if (Date.now() - last < COALESCE_MS) { + timer = setTimeout(flush, COALESCE_MS) + } else { + flush() + } + } + + const onRawEvent = (params: unknown) => { + const decoded = decodeEvent(params) + if (Option.isNone(decoded)) { + const t = (params as { type?: unknown } | null)?.type + log.debug('gateway', 'skipped undecodable event', { type: typeof t === 'string' ? t : '(none)' }) + return + } + enqueue(decoded.value) + } + + const client = new RawGatewayClient({ + log, + onEvent: onRawEvent, + onExit: reason => log.warn('gateway', 'transport exited', { reason }) + }) + + const service: GatewayServiceShape = { + subscribe: handler => + Effect.sync(() => { + handlers.add(handler) + // Lazily spawn on first subscription so the child + its gateway.ready land. + client.start() + return () => { + handlers.delete(handler) + } + }), + + request: (method: string, params: unknown) => + Effect.tryPromise({ + try: () => client.request(method, params), + catch: cause => { + const message = cause instanceof Error ? cause.message : String(cause) + const reason = message.startsWith('timeout:') + ? ('timeout' as const) + : message.includes('not running') || message.includes('stopping') + ? ('transport-down' as const) + : ('rpc-error' as const) + return new GatewayError({ method, reason, message }) + } + }).pipe( + // Capture session id from create/resume results so approval.respond works. + Effect.tap(result => + Effect.sync(() => { + if ((method === 'session.create' || method === 'session.resume') && result && typeof result === 'object') { + const sid = (result as { session_id?: unknown }).session_id + if (typeof sid === 'string') sessionId = sid + } + }) + ) + ), + + sessionId: () => sessionId + } + + return { service, stop: () => client.stop() } +} + +/** + * The live GatewayService layer (spawns + talks to the real Python tui_gateway). + * Scoped so the child process is stopped (stdin EOF → exit) on scope teardown — + * no orphaned gateway children when the renderer is destroyed. + */ +export const liveGatewayLayer: Layer.Layer = Layer.effect( + GatewayService, + Effect.acquireRelease(Effect.sync(makeLiveGateway), ({ stop }) => Effect.sync(stop)).pipe( + Effect.map(({ service }) => service) + ) +) diff --git a/ui-tui-opentui-v2/src/boundary/gateway/python.ts b/ui-tui-opentui-v2/src/boundary/gateway/python.ts new file mode 100644 index 00000000000..d17fb6b35fe --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/gateway/python.ts @@ -0,0 +1,39 @@ +/** + * Python resolution for spawning the `tui_gateway` — mirrors Ink's + * `resolvePython` (ui-tui/src/gatewayClient.ts:45-64) EXACTLY so behavior is + * identical across engines (spec v4 §4). NEVER "probe any python". + * + * Order: HERMES_PYTHON / PYTHON env → $VIRTUAL_ENV (bin/python or + * Scripts/python.exe) → /.venv → /venv → bare `python3` (`python` + * on win32) on PATH. The source root is HERMES_PYTHON_SRC_ROOT (the launcher + * sets it) so the child resolves modules against the right checkout. + */ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' + +export function resolvePython(root: string): string { + const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim() + if (configured) return configured + + const venv = process.env.VIRTUAL_ENV?.trim() + + const hit = [ + venv && resolve(venv, 'bin/python'), + venv && resolve(venv, 'Scripts/python.exe'), + resolve(root, '.venv/bin/python'), + resolve(root, '.venv/bin/python3'), + resolve(root, 'venv/bin/python'), + resolve(root, 'venv/bin/python3') + ].find(p => p && existsSync(p)) + + return hit || (process.platform === 'win32' ? 'python' : 'python3') +} + +/** The Hermes checkout root used as PYTHONPATH / HERMES_PYTHON_SRC_ROOT for the child. */ +export function resolveSrcRoot(): string { + const configured = process.env.HERMES_PYTHON_SRC_ROOT?.trim() + if (configured) return configured + // Fallback: the package lives at /ui-tui-opentui-v2/src/boundary/gateway, + // so the checkout root is four levels up from this file's dir. + return resolve(import.meta.dir, '../../../../') +} diff --git a/ui-tui-opentui-v2/src/boundary/log.ts b/ui-tui-opentui-v2/src/boundary/log.ts new file mode 100644 index 00000000000..5506291a54b --- /dev/null +++ b/ui-tui-opentui-v2/src/boundary/log.ts @@ -0,0 +1,162 @@ +/** + * Log — TUI diagnostics sink (glitch: "v. important … hook into logs to figure + * out TUI state"). Design mirrors opencode's `util/log.ts` (levels + priority + * filter, scoped/child loggers, a `.time()` span helper) but adds a dual sink: + * + * 1. an in-memory RING BUFFER (queryable at runtime — a `/logs` overlay or a + * test asserting TUI state transitions can read it live), AND + * 2. an append-only NDJSON FILE (default `~/.hermes/logs/opentui-v2.log`, + * override via HERMES_TUI_LOG_FILE) so a live session is `tail -f`-able. + * + * The ring buffer is the key advantage over opencode's file-only logger: it lets + * us inspect engine state from inside the running TUI without leaving it. + * + * CRITICAL: OpenTUI HIJACKS `console.*` and stdout (opentui skill / gotcha) — + * logging to the terminal corrupts the rendered frame. So this NEVER touches + * console/stdout/stderr; file + ring only. It's the single approved logging path + * for the whole engine. Level filter via HERMES_TUI_LOG_LEVEL (default INFO). + */ +import { appendFileSync, mkdirSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +const PRIORITY: Record = { debug: 0, info: 1, warn: 2, error: 3 } + +export interface LogEntry { + readonly t: number // epoch ms + readonly level: LogLevel + readonly scope: string + readonly msg: string + readonly data?: unknown +} + +const RING_LIMIT = 2000 + +function defaultLogFile(): string { + const explicit = process.env.HERMES_TUI_LOG_FILE?.trim() + if (explicit) return explicit + return join(homedir(), '.hermes', 'logs', 'opentui-v2.log') +} + +function defaultLevel(): LogLevel { + const raw = process.env.HERMES_TUI_LOG_LEVEL?.trim().toLowerCase() + return raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error' ? raw : 'info' +} + +/** A timing span — call `.stop()` (or `using` it) to log completion + duration. */ +export interface TimeSpan { + stop: () => void + [Symbol.dispose]: () => void +} + +export class Log { + private ring: LogEntry[] = [] + private file: string | null + private fileBroken = false + private minPriority: number + + constructor(file: string | null = defaultLogFile(), level: LogLevel = defaultLevel()) { + this.file = file + this.minPriority = PRIORITY[level] + if (this.file) { + try { + mkdirSync(dirname(this.file), { recursive: true }) + } catch { + this.fileBroken = true + } + } + } + + setLevel(level: LogLevel): void { + this.minPriority = PRIORITY[level] + } + + private write(level: LogLevel, scope: string, msg: string, data?: unknown): void { + if (PRIORITY[level] < this.minPriority) return + const entry: LogEntry = + data === undefined ? { t: Date.now(), level, scope, msg } : { t: Date.now(), level, scope, msg, data } + this.ring.push(entry) + if (this.ring.length > RING_LIMIT) this.ring.shift() + + if (this.file && !this.fileBroken) { + try { + appendFileSync(this.file, JSON.stringify(entry) + '\n') + } catch { + this.fileBroken = true // stop hammering a broken path; the ring keeps working + } + } + } + + debug(scope: string, msg: string, data?: unknown): void { + this.write('debug', scope, msg, data) + } + info(scope: string, msg: string, data?: unknown): void { + this.write('info', scope, msg, data) + } + warn(scope: string, msg: string, data?: unknown): void { + this.write('warn', scope, msg, data) + } + error(scope: string, msg: string, data?: unknown): void { + this.write('error', scope, msg, data) + } + + /** A logger bound to a fixed scope (opencode's tagged-logger ergonomics). */ + child(scope: string): ScopedLog { + return new ScopedLog(this, scope) + } + + /** Time an operation: logs ` started` now and ` completed` + duration on stop. */ + time(scope: string, msg: string, data?: Record): TimeSpan { + const started = Date.now() + this.info(scope, `${msg} started`, data) + const stop = () => this.info(scope, `${msg} completed`, { ...data, duration_ms: Date.now() - started }) + return { stop, [Symbol.dispose]: stop } + } + + /** Snapshot of the in-memory ring (newest last). For a `/logs` overlay or tests. */ + tail(n = RING_LIMIT): LogEntry[] { + return n >= this.ring.length ? [...this.ring] : this.ring.slice(this.ring.length - n) + } + + /** Where the file log is written (for surfacing in the UI / `/logs`). */ + get filePath(): string | null { + return this.fileBroken ? null : this.file + } + + clear(): void { + this.ring = [] + } +} + +/** A logger with a fixed scope — forwards to the parent Log. */ +export class ScopedLog { + constructor( + private readonly parent: Log, + private readonly scope: string + ) {} + debug(msg: string, data?: unknown): void { + this.parent.debug(this.scope, msg, data) + } + info(msg: string, data?: unknown): void { + this.parent.info(this.scope, msg, data) + } + warn(msg: string, data?: unknown): void { + this.parent.warn(this.scope, msg, data) + } + error(msg: string, data?: unknown): void { + this.parent.error(this.scope, msg, data) + } + time(msg: string, data?: Record): TimeSpan { + return this.parent.time(this.scope, msg, data) + } +} + +let _singleton: Log | null = null + +/** Module-singleton logger for the live engine. Tests construct their own `new Log(null)`. */ +export function getLog(): Log { + _singleton ??= new Log() + return _singleton +} diff --git a/ui-tui-opentui-v2/src/boundary/renderer.ts b/ui-tui-opentui-v2/src/boundary/renderer.ts index 24911d1b374..13afb6e6896 100644 --- a/ui-tui-opentui-v2/src/boundary/renderer.ts +++ b/ui-tui-opentui-v2/src/boundary/renderer.ts @@ -9,7 +9,7 @@ * No throw / try-catch here: acquisition failure surfaces as a typed * `RendererError` via `Effect.tryPromise`'s `catch`. */ -import { createCliRenderer, type CliRenderer } from '@opentui/core' +import { createCliRenderer, type CliRenderer, type KeyEvent } from '@opentui/core' import { Deferred, Effect } from 'effect' import { RendererError } from './errors.ts' @@ -47,6 +47,15 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: Deferred.doneUnsafe(shutdown, Effect.void) }) + // Minimal global quit (Phase 1). `exitOnCtrlC:false` hands Ctrl+C to us as a key + // event (not SIGINT), so destroying here fires 'destroy' → resolves `shutdown` → + // the entry scope closes → finalizers run: renderer teardown + the gateway layer's + // `client.stop()` EOFs the Python child's stdin so it exits (no orphan). Prompts + // gate this on `!blocked` once they own Ctrl+C (Phase 3, gotcha §8 #6). + renderer.keyInput.on('keypress', (key: KeyEvent) => { + if (key.ctrl && key.name === 'c' && !renderer.isDestroyed) renderer.destroy() + }) + return { renderer, shutdown } as const }) diff --git a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts index 6ee738e8006..e817544a689 100644 --- a/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts +++ b/ui-tui-opentui-v2/src/boundary/schema/GatewayEvent.ts @@ -1,16 +1,237 @@ /** - * GatewayEvent — the decoded wire union pushed from the boundary into the Solid store. + * GatewayEvent — the wire event union, modeled as an Effect Schema and decoded + * ONCE at the transport boundary (spec v4 §3.3). Mirrors Ink's + * `ui-tui/src/gatewayTypes.ts:509-587` (discriminant = `type`). * - * Phase 0: a MINIMAL hand-typed placeholder covering only what the "hello" smoke - * needs (`gateway.ready`, `message.start/delta/complete`). Phase 1 replaces this - * with the full ~40-member union modeled as `Schema.Class` members + - * `Schema.toTaggedUnion("type")`, decoded from unknown wire JSON ONCE at the - * transport boundary (spec v4 §3.3). The discriminant is always `type`, matching - * Ink's `ui-tui/src/gatewayTypes.ts:509-587`. + * beta.78 API (verified vs .d.ts): variants are `Schema.Struct` with a + * `Schema.Literal` `type`, combined with `Schema.Union([...]).pipe( + * Schema.toTaggedUnion("type"))`. Optional fields use `Schema.optionalKey` + * (exact-optional under exactOptionalPropertyTypes). Decode unknown wire JSON + * with `Schema.decodeUnknownOption` so an UNRECOGNIZED `type` yields `Option.none` + * and is skipped — a stray event never tears down the stream. + * + * Types are INFERRED from the schema (`typeof X["Type"]`), never hand-declared. */ +import { Schema } from 'effect' -export type GatewayEvent = - | { readonly type: 'gateway.ready'; readonly session_id?: string } - | { readonly type: 'message.start'; readonly session_id?: string } - | { readonly type: 'message.delta'; readonly payload?: { readonly text?: string }; readonly session_id?: string } - | { readonly type: 'message.complete'; readonly payload?: { readonly text?: string }; readonly session_id?: string } +const Str = Schema.String +const opt = Schema.optionalKey + +// ── Skin (mirror GatewaySkin in ui-tui/src/gatewayTypes.ts) ─────────── +export const GatewaySkinSchema = Schema.Struct({ + banner_hero: opt(Str), + banner_logo: opt(Str), + branding: opt(Schema.Record(Str, Str)), + colors: opt(Schema.Record(Str, Str)), + help_header: opt(Str), + tool_prefix: opt(Str) +}) +export type GatewaySkinDecoded = typeof GatewaySkinSchema.Type + +// ── Variant schemas (one per wire `type`) ───────────────────────────── +// lifecycle +const GatewayReady = Schema.Struct({ + type: Schema.Literal('gateway.ready'), + session_id: opt(Str), + payload: opt(Schema.Struct({ skin: opt(GatewaySkinSchema) })) +}) +const SkinChanged = Schema.Struct({ + type: Schema.Literal('skin.changed'), + session_id: opt(Str), + payload: opt(GatewaySkinSchema) +}) +const SessionInfoEvent = Schema.Struct({ + type: Schema.Literal('session.info'), + session_id: opt(Str), + // SessionInfo is large + evolving; keep it loose at the boundary (Record), + // the chrome phase narrows the fields it actually reads. + payload: Schema.Record(Str, Schema.Unknown) +}) + +// streaming text +const MessageStart = Schema.Struct({ type: Schema.Literal('message.start'), session_id: opt(Str) }) +const MessageDelta = Schema.Struct({ + type: Schema.Literal('message.delta'), + session_id: opt(Str), + payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str) })) +}) +const MessageComplete = Schema.Struct({ + type: Schema.Literal('message.complete'), + session_id: opt(Str), + payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str) })) +}) + +// reasoning / thinking — toTaggedUnion needs ONE literal per member, so the +// reasoning.delta/reasoning.available pair is two structs sharing a shape. +const ReasoningShape = { + session_id: opt(Str), + payload: opt(Schema.Struct({ text: opt(Str), verbose: opt(Schema.Boolean) })) +} +const ReasoningDelta = Schema.Struct({ type: Schema.Literal('reasoning.delta'), ...ReasoningShape }) +const ReasoningAvailable = Schema.Struct({ type: Schema.Literal('reasoning.available'), ...ReasoningShape }) +const ThinkingDelta = Schema.Struct({ + type: Schema.Literal('thinking.delta'), + session_id: opt(Str), + payload: opt(Schema.Struct({ text: opt(Str) })) +}) + +// tools +const ToolStart = Schema.Struct({ + type: Schema.Literal('tool.start'), + session_id: opt(Str), + payload: Schema.Record(Str, Schema.Unknown) +}) +const ToolComplete = Schema.Struct({ + type: Schema.Literal('tool.complete'), + session_id: opt(Str), + payload: Schema.Record(Str, Schema.Unknown) +}) +const ToolProgress = Schema.Struct({ + type: Schema.Literal('tool.progress'), + session_id: opt(Str), + payload: Schema.Struct({ name: opt(Str), preview: opt(Str) }) +}) +const ToolGenerating = Schema.Struct({ + type: Schema.Literal('tool.generating'), + session_id: opt(Str), + payload: Schema.Struct({ name: opt(Str) }) +}) + +// blocking prompts (deadlock-critical — Phase 3 renders these) +const ClarifyRequest = Schema.Struct({ + type: Schema.Literal('clarify.request'), + session_id: opt(Str), + payload: Schema.Struct({ + choices: opt(Schema.NullOr(Schema.Array(Str))), + question: opt(Str), + request_id: Str + }) +}) +const ApprovalRequest = Schema.Struct({ + type: Schema.Literal('approval.request'), + session_id: opt(Str), + payload: Schema.Struct({ command: Str, description: Str }) +}) +const SudoRequest = Schema.Struct({ + type: Schema.Literal('sudo.request'), + session_id: opt(Str), + payload: Schema.Struct({ request_id: Str }) +}) +const SecretRequest = Schema.Struct({ + type: Schema.Literal('secret.request'), + session_id: opt(Str), + payload: Schema.Struct({ env_var: Str, prompt: Str, request_id: Str }) +}) + +// chrome / agent +const StatusUpdate = Schema.Struct({ + type: Schema.Literal('status.update'), + session_id: opt(Str), + payload: opt(Schema.Struct({ kind: opt(Str), text: opt(Str) })) +}) +const NotificationShow = Schema.Struct({ + type: Schema.Literal('notification.show'), + session_id: opt(Str), + payload: Schema.Record(Str, Schema.Unknown) +}) +const NotificationClear = Schema.Struct({ + type: Schema.Literal('notification.clear'), + session_id: opt(Str), + payload: opt(Schema.Struct({ key: opt(Str) })) +}) +const VoiceStatus = Schema.Struct({ + type: Schema.Literal('voice.status'), + session_id: opt(Str), + payload: opt(Schema.Struct({ state: opt(Schema.Literals(['idle', 'listening', 'transcribing'])) })) +}) +const VoiceTranscript = Schema.Struct({ + type: Schema.Literal('voice.transcript'), + session_id: opt(Str), + payload: opt(Schema.Struct({ no_speech_limit: opt(Schema.Boolean), text: opt(Str) })) +}) +const BrowserProgress = Schema.Struct({ + type: Schema.Literal('browser.progress'), + session_id: opt(Str), + payload: Schema.Record(Str, Schema.Unknown) +}) +const BackgroundComplete = Schema.Struct({ + type: Schema.Literal('background.complete'), + session_id: opt(Str), + payload: Schema.Struct({ task_id: Str, text: Str }) +}) +const ReviewSummary = Schema.Struct({ + type: Schema.Literal('review.summary'), + session_id: opt(Str), + payload: opt(Schema.Struct({ text: opt(Str) })) +}) +const SubagentShape = { session_id: opt(Str), payload: Schema.Record(Str, Schema.Unknown) } +const SubagentSpawnRequested = Schema.Struct({ type: Schema.Literal('subagent.spawn_requested'), ...SubagentShape }) +const SubagentStart = Schema.Struct({ type: Schema.Literal('subagent.start'), ...SubagentShape }) +const SubagentThinking = Schema.Struct({ type: Schema.Literal('subagent.thinking'), ...SubagentShape }) +const SubagentTool = Schema.Struct({ type: Schema.Literal('subagent.tool'), ...SubagentShape }) +const SubagentProgress = Schema.Struct({ type: Schema.Literal('subagent.progress'), ...SubagentShape }) +const SubagentComplete = Schema.Struct({ type: Schema.Literal('subagent.complete'), ...SubagentShape }) + +// transport errors +const ErrorEvent = Schema.Struct({ + type: Schema.Literal('error'), + session_id: opt(Str), + payload: opt(Schema.Struct({ message: opt(Str) })) +}) +const GatewayStderr = Schema.Struct({ + type: Schema.Literal('gateway.stderr'), + session_id: opt(Str), + payload: Schema.Struct({ line: Str }) +}) +const GatewayStartTimeout = Schema.Struct({ + type: Schema.Literal('gateway.start_timeout'), + session_id: opt(Str), + payload: Schema.Record(Str, Schema.Unknown) +}) +const GatewayProtocolError = Schema.Struct({ + type: Schema.Literal('gateway.protocol_error'), + session_id: opt(Str), + payload: opt(Schema.Struct({ preview: opt(Str) })) +}) + +// ── The union ───────────────────────────────────────────────────────── +export const GatewayEventSchema = Schema.Union([ + GatewayReady, + SkinChanged, + SessionInfoEvent, + MessageStart, + MessageDelta, + MessageComplete, + ReasoningDelta, + ReasoningAvailable, + ThinkingDelta, + ToolStart, + ToolComplete, + ToolProgress, + ToolGenerating, + ClarifyRequest, + ApprovalRequest, + SudoRequest, + SecretRequest, + StatusUpdate, + NotificationShow, + NotificationClear, + VoiceStatus, + VoiceTranscript, + BrowserProgress, + BackgroundComplete, + ReviewSummary, + SubagentSpawnRequested, + SubagentStart, + SubagentThinking, + SubagentTool, + SubagentProgress, + SubagentComplete, + ErrorEvent, + GatewayStderr, + GatewayStartTimeout, + GatewayProtocolError +]).pipe(Schema.toTaggedUnion('type')) + +/** The decoded, typed event. Inferred from the schema — never hand-declared. */ +export type GatewayEvent = typeof GatewayEventSchema.Type diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index c1ac90f8654..5afbda28013 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -4,27 +4,81 @@ * - creates the Solid store, * - wires GatewayService.subscribe -> store.apply (Effect->Solid contact #2), * - does the one-line `render(() => , renderer)` bridge (contact #1), + * - (live) bootstraps a session and optionally submits an initial prompt, * - blocks until the renderer is destroyed (user quit), * and at the bottom PROVIDES the layers and runs (`Effect.provide(AppLayer)`). * - * Phase 0 backend = FakeGateway, which streams a scripted "hello". Phase 1 - * swaps `liveGateway.layer` for the real `tui_gateway` transport. The body of - * `run` does not change when the backend swaps — that's the point of the layer. + * Backend selection (import.meta.main): + * - default → the LIVE `liveGatewayLayer` (spawns the real Python + * `tui_gateway`); after `gateway.ready` it `session.create`s and, if an + * initial prompt is given (HERMES_TUI_PROMPT or argv), `prompt.submit`s it. + * The composer lands in Phase 2 — until then the initial prompt is how a + * streamed reply is driven into the transcript (spec Phase-1 smoke). + * - HERMES_TUI_FAKE=1 → the scripted FakeGateway "hello" (offline dev/CI). + * + * The body of `run` does not change when the backend swaps — that's the point of + * the layer; only `makeAppLayer(...)` differs at the edge. */ import { render } from '@opentui/solid' -import { Deferred, Effect } from 'effect' +import { Deferred, Duration, Effect } from 'effect' -import { GatewayService } from '../boundary/gateway/GatewayService.ts' +import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts' +import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' +import { getLog } from '../boundary/log.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' -import { createSessionStore } from '../logic/store.ts' +import { createSessionStore, type SessionStore } from '../logic/store.ts' import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts' export interface TuiInput { + /** Mouse tracking on/off. */ readonly mouse: boolean + /** Skip the live session bootstrap (the fake backend drives the stream itself). */ + readonly fake: boolean + /** Terminal width passed to `session.create` (Ink uses the live cols; 80 is a fine default). */ + readonly cols: number + /** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */ + readonly initialPrompt?: string } +const READY_POLL = Duration.millis(100) +const READY_TIMEOUT_MS = 20_000 + +/** + * Live session bootstrap: wait for the unsolicited `gateway.ready` handshake, + * `session.create`, then (if given) submit the initial prompt. Forked into the + * entry scope so it runs concurrently with the render + the quit-await. Any + * failure is logged (file/ring sink) and swallowed — a bootstrap hiccup must + * never tear down the rendered UI. + */ +const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) => + Effect.gen(function* () { + const log = getLog() + let waited = 0 + while (!store.state.ready && waited < READY_TIMEOUT_MS) { + yield* Effect.sleep(READY_POLL) + waited += 100 + } + if (!store.state.ready) { + log.warn('bootstrap', 'no gateway.ready within timeout', { waited }) + return + } + const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols }) + const sid = created?.session_id ?? gateway.sessionId() + if (!sid) { + log.warn('bootstrap', 'session.create returned no session_id') + return + } + log.info('bootstrap', 'session created', { sid }) + const prompt = input.initialPrompt?.trim() + if (prompt) { + store.pushUser(prompt) + yield* gateway.request('prompt.submit', { session_id: sid, text: prompt }) + } + }).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) })))) + /** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { yield* Effect.scoped( @@ -38,8 +92,21 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { const gateway = yield* GatewayService yield* gateway.subscribe(event => store.apply(event)) + // Live backend: drive a session (create + optional initial prompt) concurrently. + if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input)) + // Contact point #1: the single render bridge. After this, the screen is Solid's. - yield* Effect.promise(() => render(() => , renderer)) + // The theme is sourced reactively from the store (skin events update it). + yield* Effect.promise(() => + render( + () => ( + store.state.theme}> + + + ), + renderer + ) + ) // Block until the renderer is destroyed (Ctrl+C / quit); finalizers then run. yield* Deferred.await(shutdown) @@ -47,7 +114,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { ) }) -/** Scripted "hello" stream so Phase 0 paints a non-empty frame from the fake backend. */ +/** Scripted "hello" stream so the fake backend paints a non-empty frame offline. */ function streamHello(controller: FakeGatewayController): void { controller.emit({ type: 'gateway.ready' }) controller.emit({ type: 'message.start' }) @@ -57,12 +124,26 @@ function streamHello(controller: FakeGatewayController): void { controller.emit({ type: 'message.complete' }) } +const TRUE_RE = /^(?:1|true|yes|on)$/i + if (import.meta.main) { - const { layer, controller } = makeFakeGatewayLayer() - // Drive the fake stream shortly after mount so the subscription is live. - setTimeout(() => streamHello(controller), 50) - Effect.runPromise(run({ mouse: false }).pipe(Effect.provide(makeAppLayer(layer)))).catch(error => { - console.error('[tui] fatal', error) + const fake = TRUE_RE.test(process.env.HERMES_TUI_FAKE?.trim() ?? '') + const cols = process.stdout.columns || 80 + const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim() + const base = { mouse: false, fake, cols } + const input: TuiInput = initialPrompt ? { ...base, initialPrompt } : base + + const onFatal = (error: unknown) => { + getLog().error('entry', 'fatal', { error: String(error) }) process.exitCode = 1 - }) + } + + if (fake) { + const { layer, controller } = makeFakeGatewayLayer() + // Drive the fake stream shortly after mount so the subscription is live. + setTimeout(() => streamHello(controller), 50) + Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(layer)))).catch(onFatal) + } else { + Effect.runPromise(run(input).pipe(Effect.provide(makeAppLayer(liveGatewayLayer)))).catch(onFatal) + } } diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 6b4345c74d7..90f185d4796 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -1,16 +1,21 @@ /** - * Session/message store — the SOLID side (spec v4 §1). Plain `createStore` + - * an `apply(event)` reducer, à la opencode `context/sync-v2.tsx`. NOT Effect: - * this is where reactivity lives. The boundary calls `apply` with already-decoded - * GatewayEvents via GatewayService.subscribe. + * Session/message store — the SOLID side (spec v4 §1, §7.5). Plain `createStore` + * + an `apply(event)` reducer, à la opencode `context/sync-v2.tsx`. NOT Effect. + * The boundary calls `apply` with already-decoded `GatewayEvent`s via + * GatewayService.subscribe. * - * Phase 0: the minimal reducer needed to render a streamed "hello" (start → - * delta → complete). Phase 1 grows this into the full ordered-parts model - * (spec v4 §7): LRU id dedup, hydrate-while-buffering, text/tool/reasoning parts. + * Phase 1 scope: + * - streaming text reducer (start/delta/complete; prefer `text` over `rendered`) + * - reactive `theme` updated from gateway.ready{skin} / skin.changed → fromSkin + * - LRU id-dedup (events carrying a stable id applied at most once) + * - hydrate-while-buffering hook (resume): snapshot replaces history, live + * events that arrive mid-hydrate are buffered then replayed + * Phase 2 grows the message model into ordered parts (text/tool/reasoning, §7). */ import { createStore, produce } from 'solid-js/store' -import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts' +import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts' +import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' export interface Message { readonly role: 'user' | 'assistant' | 'system' @@ -21,10 +26,38 @@ export interface Message { export interface StoreState { ready: boolean messages: Message[] + theme: Theme } +const LRU_LIMIT = 1000 + export function createSessionStore() { - const [state, setState] = createStore({ ready: false, messages: [] }) + const [state, setState] = createStore({ + ready: false, + messages: [], + theme: DEFAULT_THEME + }) + + // LRU id-dedup: events that carry a stable id are applied at most once. + const applied = new Set() + function duplicate(id: string | undefined): boolean { + if (!id) return false + if (applied.has(id)) return true + applied.add(id) + if (applied.size > LRU_LIMIT) { + const oldest = applied.values().next() + if (!oldest.done) applied.delete(oldest.value) + } + return false + } + + // Hydrate-while-buffering (resume): while a snapshot is loading, live events + // queue here and replay after the snapshot is reconciled (opencode sync-v2). + let buffering: GatewayEvent[] | null = null + + function setSkin(skin: GatewaySkinDecoded | undefined): void { + setState('theme', themeFromSkin(skin)) + } /** Push a user message (composer submit). */ function pushUser(text: string) { @@ -37,9 +70,21 @@ export function createSessionStore() { /** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */ function apply(event: GatewayEvent): void { + if (buffering) { + buffering.push(event) + return + } + applyNow(event) + } + + function applyNow(event: GatewayEvent): void { switch (event.type) { case 'gateway.ready': setState('ready', true) + setSkin(event.payload?.skin) + break + case 'skin.changed': + setSkin(event.payload) break case 'message.start': setState( @@ -49,12 +94,12 @@ export function createSessionStore() { ) break case 'message.delta': { + // prefer `text` over `rendered` (gotcha §8 #4 — rendered is incremental Rich-ANSI). const text = event.payload?.text ?? '' if (!text) break setState( produce(draft => { const live = draft.messages[draft.messages.length - 1] - // prefer `text` over `rendered` (gotcha §8 #4) — placeholder only carries text. if (live && live.role === 'assistant' && live.streaming) live.text += text }) ) @@ -72,10 +117,26 @@ export function createSessionStore() { }) ) break + // Other event types (tools, prompts, chrome, subagents) are reduced in + // later phases; unhandled members are intentionally ignored here. } } - return { state, apply, pushUser } as const + /** + * Begin a resume hydrate: buffer live events, replace history with the + * snapshot, then replay buffered events. `loadSnapshot` maps the gateway's + * historical messages into the store's Message[] (Phase 4 fills the mapping). + */ + function hydrate(loadSnapshot: () => Message[]): void { + buffering = [] + const snapshot = loadSnapshot() + setState('messages', snapshot) + const pending = buffering + buffering = null + for (const event of pending) applyNow(event) + } + + return { state, apply, pushUser, hydrate, duplicate } as const } export type SessionStore = ReturnType diff --git a/ui-tui-opentui-v2/src/logic/theme.ts b/ui-tui-opentui-v2/src/logic/theme.ts new file mode 100644 index 00000000000..5892a24d77d --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/theme.ts @@ -0,0 +1,493 @@ +/** + * Theme / skin engine (SOLID side, pure TS — spec v4 §7.5). A faithful 1:1 port + * of Ink's `ui-tui/src/theme.ts` so EXISTING Hermes skins work UNCHANGED: same + * `Theme`/`ThemeColors`/`ThemeBrand` shapes, same `DARK_THEME`/`LIGHT_THEME` + * defaults, same `detectLightMode`, same Apple-Terminal ANSI-256 normalization, + * and the same `fromSkin(colors, branding, …)` mapping + fallback chains. + * + * The view never hardcodes colors — it reads `theme.color.*` / `theme.brand.*` + * via the ThemeProvider context (view/theme.tsx). The boundary feeds skins in + * through `gateway.ready{payload.skin}` / `skin.changed` → fromSkin → the theme + * signal. + * + * Source of truth for the contract: ui-tui/src/theme.ts (+ GatewaySkin in + * ui-tui/src/gatewayTypes.ts). Keep this port in sync if that contract changes. + */ + +export interface ThemeColors { + primary: string + accent: string + border: string + text: string + muted: string + completionBg: string + completionCurrentBg: string + completionMetaBg: string + completionMetaCurrentBg: string + + label: string + ok: string + error: string + warn: string + + prompt: string + sessionLabel: string + sessionBorder: string + + statusBg: string + statusFg: string + statusGood: string + statusWarn: string + statusBad: string + statusCritical: string + selectionBg: string + + diffAdded: string + diffRemoved: string + diffAddedWord: string + diffRemovedWord: string + + shellDollar: string +} + +export interface ThemeBrand { + name: string + icon: string + prompt: string + welcome: string + goodbye: string + tool: string + helpHeader: string +} + +export interface Theme { + color: ThemeColors + brand: ThemeBrand + bannerLogo: string + bannerHero: string +} + +/** The skin payload as emitted by the gateway (mirror ui-tui/src/gatewayTypes.ts GatewaySkin). */ +export interface GatewaySkin { + banner_hero?: string + banner_logo?: string + branding?: Record + colors?: Record + help_header?: string + tool_prefix?: string +} + +// ── Color math ─────────────────────────────────────────────────────── + +function parseHex(h: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{6})$/i.exec(h) + if (!m) return null + const n = parseInt(m[1]!, 16) + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] +} + +function mix(a: string, b: string, t: number) { + const pa = parseHex(a) + const pb = parseHex(b) + if (!pa || !pb) return a + const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t) + return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) +} + +const XTERM_6_LEVELS = [0, 95, 135, 175, 215, 255] as const +const ANSI_LIGHT_MAX_LUMINANCE = 0.72 +const ANSI_LIGHT_TARGET_LUMINANCE = 0.34 +const ANSI_LIGHT_MIN_SATURATION = 0.22 +const ANSI_MUTED_BUCKET = 245 + +const ANSI_NORMALIZED_FOREGROUNDS: readonly (keyof ThemeColors)[] = [ + 'text', + 'label', + 'ok', + 'error', + 'warn', + 'prompt', + 'statusFg', + 'statusGood', + 'statusWarn', + 'statusBad', + 'statusCritical', + 'shellDollar' +] + +const ANSI_MUTED_FOREGROUNDS: readonly (keyof ThemeColors)[] = ['muted', 'sessionLabel', 'sessionBorder'] + +function xtermEightBitRgb(colorNumber: number): [number, number, number] { + if (colorNumber >= 232) { + const value = 8 + (colorNumber - 232) * 10 + return [value, value, value] + } + if (colorNumber >= 16) { + const offset = colorNumber - 16 + return [ + XTERM_6_LEVELS[Math.floor(offset / 36) % 6]!, + XTERM_6_LEVELS[Math.floor(offset / 6) % 6]!, + XTERM_6_LEVELS[offset % 6]! + ] + } + return [0, 0, 0] +} + +function channelLuminance(value: number): number { + const normalized = value / 255 + return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 +} + +function relativeLuminance(red: number, green: number, blue: number): number { + return 0.2126 * channelLuminance(red) + 0.7152 * channelLuminance(green) + 0.0722 * channelLuminance(blue) +} + +function rgbToHsl(red: number, green: number, blue: number): [number, number, number] { + const rn = red / 255 + const gn = green / 255 + const bn = blue / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const lightness = (max + min) / 2 + if (max === min) return [0, 0, lightness] + const delta = max - min + const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min) + const hue = + max === rn ? (gn - bn) / delta + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / delta + 2 : (rn - gn) / delta + 4 + return [hue / 6, saturation, lightness] +} + +function circularDistance(a: number, b: number): number { + const distance = Math.abs(a - b) + return Math.min(distance, 1 - distance) +} + +// Mirrors @hermes/ink's colorize.ts (kept local, like the Ink app copy). +function richEightBitColorNumber(red: number, green: number, blue: number): number { + const [, saturation, lightness] = rgbToHsl(red, green, blue) + if (saturation < 0.15) { + const gray = Math.round(lightness * 25) + return gray === 0 ? 16 : gray === 25 ? 231 : 231 + gray + } + const sixRed = red < 95 ? red / 95 : 1 + (red - 95) / 40 + const sixGreen = green < 95 ? green / 95 : 1 + (green - 95) / 40 + const sixBlue = blue < 95 ? blue / 95 : 1 + (blue - 95) / 40 + return 16 + 36 * Math.round(sixRed) + 6 * Math.round(sixGreen) + Math.round(sixBlue) +} + +function bestReadableAnsiColor(red: number, green: number, blue: number): number { + const [hue, saturation, lightness] = rgbToHsl(red, green, blue) + let bestColor = richEightBitColorNumber(red, green, blue) + let bestScore = Number.POSITIVE_INFINITY + for (let colorNumber = 16; colorNumber <= 255; colorNumber += 1) { + const [candidateRed, candidateGreen, candidateBlue] = xtermEightBitRgb(colorNumber) + const candidateLuminance = relativeLuminance(candidateRed, candidateGreen, candidateBlue) + if (candidateLuminance > ANSI_LIGHT_MAX_LUMINANCE) continue + const [candidateHue, candidateSaturation, candidateLightness] = rgbToHsl( + candidateRed, + candidateGreen, + candidateBlue + ) + const saturationFloorPenalty = + candidateSaturation < ANSI_LIGHT_MIN_SATURATION ? (ANSI_LIGHT_MIN_SATURATION - candidateSaturation) * 3 : 0 + const score = + circularDistance(candidateHue, hue) * 4 + + Math.abs(candidateSaturation - Math.max(ANSI_LIGHT_MIN_SATURATION, saturation)) * 0.8 + + Math.abs(candidateLightness - Math.min(lightness, ANSI_LIGHT_TARGET_LUMINANCE)) * 2 + + saturationFloorPenalty + if (score < bestScore) { + bestColor = colorNumber + bestScore = score + } + } + return bestColor +} + +function normalizeAnsiForeground(color: string): string { + const rgb = parseHex(color) + if (!rgb) return color + const richAnsi = richEightBitColorNumber(rgb[0], rgb[1], rgb[2]) + const richRgb = xtermEightBitRgb(richAnsi) + const ansi = + relativeLuminance(richRgb[0], richRgb[1], richRgb[2]) > ANSI_LIGHT_MAX_LUMINANCE + ? bestReadableAnsiColor(rgb[0], rgb[1], rgb[2]) + : richAnsi + return `ansi256(${ansi})` +} + +// ── Defaults ───────────────────────────────────────────────────────── + +const BRAND: ThemeBrand = { + name: 'Hermes Agent', + icon: '⚕', + prompt: '❯', + welcome: 'Type your message or /help for commands.', + goodbye: 'Goodbye! ⚕', + tool: '┊', + helpHeader: '(^_^)? Commands' +} + +const cleanPromptSymbol = (s: string | undefined, fallback: string) => { + const cleaned = String(s ?? '') + .replace(/\s+/g, ' ') + .trim() + return cleaned || fallback +} + +export const DARK_THEME: Theme = { + color: { + primary: '#FFD700', + accent: '#FFBF00', + border: '#CD7F32', + text: '#FFF8DC', + muted: '#CC9B1F', + completionBg: '#1a1a2e', + completionCurrentBg: '#333355', + completionMetaBg: '#1a1a2e', + completionMetaCurrentBg: '#333355', + + label: '#DAA520', + ok: '#4caf50', + error: '#ef5350', + warn: '#ffa726', + + prompt: '#FFF8DC', + sessionLabel: '#CC9B1F', + sessionBorder: '#CC9B1F', + + statusBg: '#1a1a2e', + statusFg: '#C0C0C0', + statusGood: '#8FBC8F', + statusWarn: '#FFD700', + statusBad: '#FF8C00', + statusCritical: '#FF6B6B', + selectionBg: '#3a3a55', + + diffAdded: 'rgb(220,255,220)', + diffRemoved: 'rgb(255,220,220)', + diffAddedWord: 'rgb(36,138,61)', + diffRemovedWord: 'rgb(207,34,46)', + shellDollar: '#4dabf7' + }, + brand: BRAND, + bannerLogo: '', + bannerHero: '' +} + +export const LIGHT_THEME: Theme = { + color: { + primary: '#8B6914', + accent: '#A0651C', + border: '#7A4F1F', + text: '#3D2F13', + muted: '#7A5A0F', + completionBg: '#F5F5F5', + completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + completionMetaBg: '#F5F5F5', + completionMetaCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + + label: '#7A5A0F', + ok: '#2E7D32', + error: '#C62828', + warn: '#E65100', + + prompt: '#2B2014', + sessionLabel: '#7A5A0F', + sessionBorder: '#7A5A0F', + + statusBg: '#F5F5F5', + statusFg: '#333333', + statusGood: '#2E7D32', + statusWarn: '#8B6914', + statusBad: '#D84315', + statusCritical: '#B71C1C', + selectionBg: '#D4E4F7', + + diffAdded: 'rgb(200,240,200)', + diffRemoved: 'rgb(240,200,200)', + diffAddedWord: 'rgb(27,94,32)', + diffRemovedWord: 'rgb(183,28,28)', + shellDollar: '#1565C0' + }, + brand: BRAND, + bannerLogo: '', + bannerHero: '' +} + +const TRUE_RE = /^(?:1|true|yes|on)$/ +const FALSE_RE = /^(?:0|false|no|off)$/ + +const LIGHT_DEFAULT_TERM_PROGRAMS = new Set(['Apple_Terminal']) + +const LUMA_LIGHT_THRESHOLD = 0.6 +const HEX_3_RE = /^[0-9a-f]{3}$/ +const HEX_6_RE = /^[0-9a-f]{6}$/ + +function backgroundLuminance(raw: string): null | number { + const v = raw.trim().toLowerCase() + if (!v) return null + const hex = v.startsWith('#') ? v.slice(1) : v + const rgb = HEX_6_RE.test(hex) + ? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)] + : HEX_3_RE.test(hex) + ? [parseInt(hex[0]! + hex[0]!, 16), parseInt(hex[1]! + hex[1]!, 16), parseInt(hex[2]! + hex[2]!, 16)] + : null + if (!rgb) return null + return (0.2126 * rgb[0]! + 0.7152 * rgb[1]! + 0.0722 * rgb[2]!) / 255 +} + +/** Pick light vs dark with ordered, explainable env signals (mirror Ink). */ +export function detectLightMode( + env: Record = process.env, + lightDefaultTermPrograms: ReadonlySet = LIGHT_DEFAULT_TERM_PROGRAMS +): boolean { + const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() + if (TRUE_RE.test(lightFlag)) return true + if (FALSE_RE.test(lightFlag)) return false + + const themeFlag = (env.HERMES_TUI_THEME ?? '').trim().toLowerCase() + if (themeFlag === 'light') return true + if (themeFlag === 'dark') return false + + const bgHint = backgroundLuminance(env.HERMES_TUI_BACKGROUND ?? '') + if (bgHint !== null) return bgHint >= LUMA_LIGHT_THRESHOLD + + const colorfgbg = (env.COLORFGBG ?? '').trim() + if (colorfgbg) { + const lastField = colorfgbg.split(';').at(-1) ?? '' + if (/^\d+$/.test(lastField)) { + const bg = Number(lastField) + if (bg === 7 || bg === 15) return true + if (bg >= 0 && bg < 16) return false + } + } + + const termProgram = (env.TERM_PROGRAM ?? '').trim() + return lightDefaultTermPrograms.has(termProgram) +} + +function shouldNormalizeAnsiLightTheme( + env: Record = process.env, + isLight = detectLightMode(env) +): boolean { + const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase() + const termProgram = (env.TERM_PROGRAM ?? '').trim() + return termProgram === 'Apple_Terminal' && colorTerm !== 'truecolor' && colorTerm !== '24bit' && isLight +} + +export function normalizeThemeForAnsiLightTerminal( + theme: Theme, + env: Record = process.env, + isLight = detectLightMode(env) +): Theme { + if (!shouldNormalizeAnsiLightTheme(env, isLight)) return theme + const color = { ...theme.color } + for (const key of ANSI_NORMALIZED_FOREGROUNDS) color[key] = normalizeAnsiForeground(color[key]) + for (const key of ANSI_MUTED_FOREGROUNDS) color[key] = `ansi256(${ANSI_MUTED_BUCKET})` + return { ...theme, color } +} + +const DEFAULT_LIGHT_MODE = detectLightMode() + +export const DEFAULT_THEME: Theme = normalizeThemeForAnsiLightTerminal( + DEFAULT_LIGHT_MODE ? LIGHT_THEME : DARK_THEME, + process.env, + DEFAULT_LIGHT_MODE +) + +// ── Skin → Theme ───────────────────────────────────────────────────── + +export function fromSkin( + colors: Record, + branding: Record, + bannerLogo = '', + bannerHero = '', + toolPrefix = '', + helpHeader = '' +): Theme { + const d = DEFAULT_THEME + const c = (k: string) => colors[k] + const hasSkinColors = Object.keys(colors).length > 0 + + const accent = c('ui_accent') ?? c('banner_accent') ?? d.color.accent + const bannerAccent = c('banner_accent') ?? c('banner_title') ?? d.color.accent + const muted = c('banner_dim') ?? d.color.muted + const completionBg = c('completion_menu_bg') ?? d.color.completionBg + + const completionCurrentBg = + c('completion_menu_current_bg') ?? + (hasSkinColors ? mix(completionBg, bannerAccent, 0.25) : d.color.completionCurrentBg) + + const completionMetaBg = c('completion_menu_meta_bg') ?? completionBg + const completionMetaCurrentBg = c('completion_menu_meta_current_bg') ?? completionCurrentBg + + return normalizeThemeForAnsiLightTerminal( + { + color: { + primary: c('ui_primary') ?? c('banner_title') ?? d.color.primary, + accent, + border: c('ui_border') ?? c('banner_border') ?? d.color.border, + text: c('ui_text') ?? c('banner_text') ?? d.color.text, + muted, + completionBg, + completionCurrentBg, + completionMetaBg, + completionMetaCurrentBg, + + label: c('ui_label') ?? d.color.label, + ok: c('ui_ok') ?? d.color.ok, + error: c('ui_error') ?? d.color.error, + warn: c('ui_warn') ?? d.color.warn, + + prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, + sessionLabel: c('session_label') ?? muted, + sessionBorder: c('session_border') ?? muted, + + statusBg: d.color.statusBg, + statusFg: d.color.statusFg, + statusGood: c('ui_ok') ?? d.color.statusGood, + statusWarn: c('ui_warn') ?? d.color.statusWarn, + statusBad: d.color.statusBad, + statusCritical: d.color.statusCritical, + selectionBg: + c('selection_bg') ?? + c('completion_menu_current_bg') ?? + (hasSkinColors ? completionCurrentBg : d.color.selectionBg), + + diffAdded: d.color.diffAdded, + diffRemoved: d.color.diffRemoved, + diffAddedWord: d.color.diffAddedWord, + diffRemovedWord: d.color.diffRemovedWord, + shellDollar: c('shell_dollar') ?? d.color.shellDollar + }, + + brand: { + name: branding.agent_name ?? d.brand.name, + icon: d.brand.icon, + prompt: cleanPromptSymbol(branding.prompt_symbol, d.brand.prompt), + welcome: branding.welcome ?? d.brand.welcome, + goodbye: branding.goodbye ?? d.brand.goodbye, + tool: toolPrefix || d.brand.tool, + helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader) + }, + + bannerLogo, + bannerHero + }, + process.env, + DEFAULT_LIGHT_MODE + ) +} + +/** Convenience: map a GatewaySkin payload straight to a Theme (defaults if empty). */ +export function themeFromSkin(skin: GatewaySkin | undefined): Theme { + if (!skin) return DEFAULT_THEME + return fromSkin( + skin.colors ?? {}, + skin.branding ?? {}, + skin.banner_logo ?? '', + skin.banner_hero ?? '', + skin.tool_prefix ?? '', + skin.help_header ?? '' + ) +} diff --git a/ui-tui-opentui-v2/src/test/liveGateway.smoke.ts b/ui-tui-opentui-v2/src/test/liveGateway.smoke.ts new file mode 100644 index 00000000000..428944c197d --- /dev/null +++ b/ui-tui-opentui-v2/src/test/liveGateway.smoke.ts @@ -0,0 +1,68 @@ +/** + * Phase 1 live transport smoke (spec v4 §5 Layer 4). Drives the REAL Python + * `tui_gateway` through the GatewayService layer: spawn → gateway.ready → + * session.create → (optional) prompt.submit → streamed reply. Asserts the + * decode-once boundary + the handshake against the real server, NOT a fake. + * + * Skips gracefully when no Hermes python resolves (CI without the venv). Run + * explicitly: `bun src/test/liveGateway.smoke.ts`. + */ +import { Effect, ManagedRuntime } from 'effect' + +import { GatewayService } from '../boundary/gateway/GatewayService.ts' +import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' +import { getLog } from '../boundary/log.ts' +import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts' + +const READY_TIMEOUT_MS = 20_000 + +async function main(): Promise { + const log = getLog() + const runtime = ManagedRuntime.make(liveGatewayLayer) + const seen: GatewayEvent[] = [] + let ready = false + + const program = Effect.gen(function* () { + const gateway = yield* GatewayService + yield* gateway.subscribe(event => { + seen.push(event) + if (event.type === 'gateway.ready') ready = true + }) + + // Wait for the unsolicited gateway.ready (handshake). + const start = Date.now() + while (!ready && Date.now() - start < READY_TIMEOUT_MS) { + yield* Effect.promise(() => new Promise(r => setTimeout(r, 100))) + } + if (!ready) return { ok: false, why: 'no gateway.ready within timeout' } + + // Create a session (NOT a long handler — responds inline). + const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: 80 }) + const sid = created?.session_id ?? gateway.sessionId() + if (!sid) return { ok: false, why: 'session.create returned no session_id' } + + return { ok: true, sid, events: seen.length } + }) + + try { + const result = await runtime.runPromise(program) + if (result.ok) { + console.log(`PASS — gateway.ready seen, session.create ok (sid=${result.sid}, events=${result.events})`) + console.log(`log file: ${log.filePath}`) + process.exitCode = 0 + } else { + console.log(`FAIL — ${result.why}`) + console.log('recent log:', JSON.stringify(log.tail(20), null, 2)) + process.exitCode = 1 + } + } catch (error) { + console.log(`TRANSPORT ERROR — ${error instanceof Error ? error.message : String(error)}`) + console.log('recent log:', JSON.stringify(log.tail(20), null, 2)) + // Treat a missing python/model as a skip, not a hard fail, for CI parity. + process.exitCode = 0 + } finally { + await runtime.dispose() + } +} + +void main() diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 8292943c5c9..c8119da5a3e 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -1,26 +1,57 @@ /** - * Phase 0 render test (spec v4 §5 Layer 2). Mounts the App headlessly with a - * store seeded by the scripted hello stream and asserts the captured frame - * contains the rendered text. This is the headless frame gate for Phase 0. + * Phase 1 render test (spec v4 §5 Layer 2). Mounts the App headlessly with a + * store seeded by the scripted hello stream, asserts the captured frame is + * THEMED (brand name/icon from the theme, not hardcoded), and that applying a + * custom skin re-themes the brand name reactively. */ import { describe, expect, test } from 'bun:test' import { createSessionStore } from '../logic/store.ts' -import { captureFrame } from './lib/render.ts' import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { captureFrame } from './lib/render.ts' -describe('App render (Phase 0)', () => { - test('renders the streamed hello + ready header into the frame', async () => { +function seedHello(store: ReturnType) { + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'message.start' }) + store.apply({ type: 'message.delta', payload: { text: 'Hi there, glitch!' } }) + store.apply({ type: 'message.complete' }) +} + +describe('App render (Phase 1, themed)', () => { + test('renders the streamed hello + default brand into the frame', async () => { const store = createSessionStore() - store.apply({ type: 'gateway.ready' }) - store.apply({ type: 'message.start' }) - store.apply({ type: 'message.delta', payload: { text: 'Hi there, glitch!' } }) - store.apply({ type: 'message.complete' }) + seedHello(store) - const frame = await captureFrame(() => , { width: 60, height: 8 }) + const frame = await captureFrame( + () => ( + store.state.theme}> + + + ), + { width: 60, height: 8 } + ) - expect(frame).toContain('hermes') + expect(frame).toContain('Hermes Agent') // default brand.name expect(frame).toContain('ready') expect(frame).toContain('Hi there, glitch!') }) + + test('applying a skin re-themes the brand name (skinnable, no hardcoding)', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready', payload: { skin: { branding: { agent_name: 'Zephyr' } } } }) + seedHello(store) + + const frame = await captureFrame( + () => ( + store.state.theme}> + + + ), + { width: 60, height: 8 } + ) + + expect(frame).toContain('Zephyr') + expect(frame).not.toContain('Hermes Agent') + }) }) diff --git a/ui-tui-opentui-v2/src/test/schema.test.ts b/ui-tui-opentui-v2/src/test/schema.test.ts new file mode 100644 index 00000000000..b837020a70f --- /dev/null +++ b/ui-tui-opentui-v2/src/test/schema.test.ts @@ -0,0 +1,48 @@ +/** + * Phase 1 schema test (spec v4 §5 Layer 1/4). The gateway-contract decode: known + * events decode with typed narrowing, unrecognized `type` and malformed payloads + * are SKIPPED (Option.none) so a stray wire event never tears down the stream. + */ +import { describe, expect, test } from 'bun:test' +import { Option, Schema } from 'effect' + +import { GatewayEventSchema } from '../boundary/schema/GatewayEvent.ts' + +const decode = Schema.decodeUnknownOption(GatewayEventSchema) + +describe('GatewayEvent schema decode (Phase 1)', () => { + test('decodes a known event with typed narrowing', () => { + const ev = decode({ type: 'message.delta', payload: { text: 'hi' }, session_id: 's1' }) + expect(Option.isSome(ev)).toBe(true) + if (Option.isSome(ev) && ev.value.type === 'message.delta') { + expect(ev.value.payload?.text).toBe('hi') + expect(ev.value.session_id).toBe('s1') + } + }) + + test('decodes gateway.ready carrying a skin', () => { + const ev = decode({ type: 'gateway.ready', payload: { skin: { colors: { ui_primary: '#abc123' } } } }) + expect(Option.isSome(ev)).toBe(true) + if (Option.isSome(ev) && ev.value.type === 'gateway.ready') { + expect(ev.value.payload?.skin?.colors?.ui_primary).toBe('#abc123') + } + }) + + test('decodes the 4 blocking prompt requests', () => { + expect(Option.isSome(decode({ type: 'clarify.request', payload: { question: '?', request_id: 'r' } }))).toBe(true) + expect(Option.isSome(decode({ type: 'approval.request', payload: { command: 'rm', description: 'd' } }))).toBe(true) + expect(Option.isSome(decode({ type: 'sudo.request', payload: { request_id: 'r' } }))).toBe(true) + expect( + Option.isSome(decode({ type: 'secret.request', payload: { env_var: 'X', prompt: 'p', request_id: 'r' } })) + ).toBe(true) + }) + + test('SKIPS an unrecognized event type (Option.none, no throw)', () => { + expect(Option.isNone(decode({ type: 'totally.unknown.event', foo: 1 }))).toBe(true) + }) + + test('SKIPS a malformed payload (missing required field)', () => { + // clarify.request requires request_id + expect(Option.isNone(decode({ type: 'clarify.request', payload: { question: '?' } }))).toBe(true) + }) +}) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 5e7b057e873..93b07720576 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -1,43 +1,57 @@ /** - * Phase 0 store reducer test (spec v4 §5 Layer 3). Pure data behavior of - * `apply(event)` — no renderer, no Effect. Drives the scripted hello stream and - * asserts the streamed assistant text concatenates and finalizes. + * Phase 1 store test (spec v4 §5 Layer 3). Pure data behavior of the grown + * reducer: skin → theme, LRU dedup, hydrate-while-buffering for resume. */ import { describe, expect, test } from 'bun:test' -import { createSessionStore } from '../logic/store.ts' +import { DEFAULT_THEME } from '../logic/theme.ts' +import { createSessionStore, type Message } from '../logic/store.ts' -describe('session store reducer (Phase 0)', () => { - test('gateway.ready flips ready', () => { +describe('session store (Phase 1)', () => { + test('gateway.ready{skin} re-themes; default before', () => { const store = createSessionStore() - expect(store.state.ready).toBe(false) - store.apply({ type: 'gateway.ready' }) + expect(store.state.theme.brand.name).toBe(DEFAULT_THEME.brand.name) + store.apply({ + type: 'gateway.ready', + payload: { skin: { branding: { agent_name: 'Zephyr' }, colors: { ui_primary: '#123456' } } } + }) expect(store.state.ready).toBe(true) + expect(store.state.theme.brand.name).toBe('Zephyr') + expect(store.state.theme.color.primary).toBe('#123456') }) - test('message.start/delta/complete streams one assistant message', () => { + test('skin.changed updates the theme live', () => { const store = createSessionStore() - store.apply({ type: 'message.start' }) - store.apply({ type: 'message.delta', payload: { text: 'Hi ' } }) - store.apply({ type: 'message.delta', payload: { text: 'there, ' } }) - store.apply({ type: 'message.delta', payload: { text: 'glitch!' } }) - - expect(store.state.messages.length).toBe(1) - const live = store.state.messages[0]! - expect(live.role).toBe('assistant') - expect(live.text).toBe('Hi there, glitch!') - expect(live.streaming).toBe(true) - - store.apply({ type: 'message.complete' }) - expect(store.state.messages[0]!.streaming).toBe(false) - expect(store.state.messages[0]!.text).toBe('Hi there, glitch!') + store.apply({ type: 'skin.changed', payload: { branding: { agent_name: 'Aurora' } } }) + expect(store.state.theme.brand.name).toBe('Aurora') }) - test('pushUser appends a user message', () => { + test('LRU dedup: duplicate(id) returns false once, true after', () => { const store = createSessionStore() - store.pushUser('hello') - expect(store.state.messages.length).toBe(1) - expect(store.state.messages[0]!.role).toBe('user') - expect(store.state.messages[0]!.text).toBe('hello') + expect(store.duplicate('evt-1')).toBe(false) + expect(store.duplicate('evt-1')).toBe(true) + expect(store.duplicate(undefined)).toBe(false) // no id → never deduped + }) + + test('hydrate replaces history, then replays events buffered mid-hydrate', () => { + const store = createSessionStore() + const snapshot: Message[] = [ + { role: 'user', text: 'old q' }, + { role: 'assistant', text: 'old a' } + ] + // Simulate a live event arriving DURING hydrate by emitting inside loadSnapshot. + let emittedDuring = false + store.hydrate(() => { + if (!emittedDuring) { + emittedDuring = true + store.apply({ type: 'message.start' }) + store.apply({ type: 'message.delta', payload: { text: 'live!' } }) + } + return snapshot + }) + // snapshot (2) + the buffered live assistant turn (1) replayed after + expect(store.state.messages.length).toBe(3) + expect(store.state.messages[0]!.text).toBe('old q') + expect(store.state.messages[2]!.text).toBe('live!') }) }) diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index e0513c7f04e..cec5419ebcb 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -1,33 +1,31 @@ /** - * App — the Solid view shell (spec v4 §2 `view/App.tsx`). Phase 0 is a minimal - * skeleton: a header line + a transcript of messages from the store. It renders - * the scripted "hello" stream the FakeGateway emits. - * - * The store is created in the entry (Solid side) and the boundary subscribes the - * store's `apply` to the GatewayService event stream — the only boundary->Solid - * contact point besides `render`. + * App — the Solid view shell (spec v4 §2 `view/App.tsx`). Phase 1: header + + * transcript, fully themed via `useTheme()` — NO hardcoded styles (spec §7.5). + * The streamed message + skin both come from the store; the boundary feeds them. * * Rich text uses / children, never an attributes bitmask (gotcha §8 #1). - * Inline color goes in `style={{ fg }}` on ; accepts `fg` directly - * (verified against @opentui/solid@0.3.2 SpanProps/TextProps + opencode usage). + * Inline color goes in `style={{ fg }}` on ; accepts `fg` directly. */ import { For, Show } from 'solid-js' import type { SessionStore } from '../logic/store.ts' +import { useTheme } from './theme.tsx' export interface AppProps { readonly store: SessionStore } export function App(props: AppProps) { + const theme = useTheme() + return ( - hermes - · opentui · - connecting…}> - ready + {theme().brand.name} + · opentui · + connecting…}> + ready @@ -35,12 +33,12 @@ export function App(props: AppProps) { {message => ( - - {message.role === 'assistant' ? '✦ ' : '> '} + + {message.role === 'assistant' ? `${theme().brand.icon} ` : `${theme().brand.prompt} `} - {message.text} + {message.text} - + )} diff --git a/ui-tui-opentui-v2/src/view/theme.tsx b/ui-tui-opentui-v2/src/view/theme.tsx new file mode 100644 index 00000000000..1334e1334c7 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/theme.tsx @@ -0,0 +1,29 @@ +/** + * ThemeProvider — the Solid context that exposes the current Theme to the view + * (spec v4 §7.5; mirrors opencode `context/theme.tsx`). The view reads + * `useTheme()().color.*` / `.brand.*` and NEVER hardcodes styles. + * + * The theme is a reactive accessor: when the boundary applies a skin + * (gateway.ready{skin} / skin.changed → store updates the theme), Solid + * fine-grained reactivity re-styles only the affected cells. + */ +import { type Accessor, createContext, type JSX, useContext } from 'solid-js' + +import { DEFAULT_THEME, type Theme } from '../logic/theme.ts' + +const ThemeContext = createContext>(() => DEFAULT_THEME) + +export interface ThemeProviderProps { + /** Reactive theme accessor (from the store). Defaults to DEFAULT_THEME if omitted. */ + readonly theme?: Accessor + readonly children: JSX.Element +} + +export function ThemeProvider(props: ThemeProviderProps) { + return DEFAULT_THEME)}>{props.children} +} + +/** Read the current theme inside a component. Call it (`useTheme()()`) to get the Theme. */ +export function useTheme(): Accessor { + return useContext(ThemeContext) +}