feat(opentui-v2): Phase 1 — live tui_gateway transport + Solid store + theming

GatewayService/liveGateway over the real Python tui_gateway: JSON-RPC stdio
framing (Bun.spawn), 16ms event coalescing flushed inside Solid batch(), typed
GatewayError, and a decode-once GatewayEvent Schema (~35-member tagged union;
unknown/malformed events skip via Option.none, never crash the stream).

The Solid sync-v2-style store grows to: streaming text concat (prefer
payload.text), gateway.ready{skin}/skin.changed -> fromSkin reactive re-theme,
LRU id-dedup, and hydrate-while-buffering (resume scaffold). Theming is a 1:1
port of Ink's theme.ts (DARK/LIGHT, detectLightMode, ANSI-256 normalization,
fromSkin) behind a Solid ThemeProvider so existing skins work unchanged and the
view carries NO hardcoded styles. A console-safe diagnostics log (in-memory
ring + NDJSON file) is the single logging path.

Entry gains a live launch path (default; HERMES_TUI_FAKE=1 -> scripted hello)
with an initial-prompt bootstrap (session.create -> prompt.submit) as the
Phase-2-composer stand-in, plus a minimal Ctrl+C graceful quit
(renderer.destroy -> shutdown Deferred -> scope finalizers -> client.stop) so
the engine reaps its own gateway child instead of orphaning it.

Verified: bun run check green (tsc + eslint + 12 tests / 4 files); live tmux
drive connect -> gateway.ready -> prompt -> streamed reply ("pong") -> clean
teardown with no orphan bun/python. Parity matrix + smoke P1 run log updated.
This commit is contained in:
alt-glitch 2026-06-08 14:19:21 +00:00
parent 12342a4bce
commit cc2c881fd1
15 changed files with 1706 additions and 95 deletions

View file

@ -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<n>", 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<typeof Bun.spawn> | null = null
private pending = new Map<string, Pending>()
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<string, string> = { ...(process.env as Record<string, string>) }
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<typeof Bun.spawn>): Promise<void> {
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<typeof Bun.spawn>): Promise<void> {
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<A = unknown>(method: string, params: unknown): Promise<A> {
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<A>((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
}
}

View file

@ -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<typeof setTimeout> | 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: <A>(method: string, params: unknown) =>
Effect.tryPromise({
try: () => client.request<A>(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<GatewayService> = Layer.effect(
GatewayService,
Effect.acquireRelease(Effect.sync(makeLiveGateway), ({ stop }) => Effect.sync(stop)).pipe(
Effect.map(({ service }) => service)
)
)

View file

@ -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) <root>/.venv <root>/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 <root>/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, '../../../../')
}

View file

@ -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<LogLevel, number> = { 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 `<msg> started` now and `<msg> completed` + duration on stop. */
time(scope: string, msg: string, data?: Record<string, unknown>): 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<string, unknown>): 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
}

View file

@ -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
})

View file

@ -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

View file

@ -4,27 +4,81 @@
* - creates the Solid store,
* - wires GatewayService.subscribe -> store.apply (Effect->Solid contact #2),
* - does the one-line `render(() => <App/>, 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(() => <App store={store} />, renderer))
// The theme is sourced reactively from the store (skin events update it).
yield* Effect.promise(() =>
render(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
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)
}
}

View file

@ -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<StoreState>({ ready: false, messages: [] })
const [state, setState] = createStore<StoreState>({
ready: false,
messages: [],
theme: DEFAULT_THEME
})
// LRU id-dedup: events that carry a stable id are applied at most once.
const applied = new Set<string>()
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<typeof createSessionStore>

View file

@ -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<string, string>
colors?: Record<string, string>
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<string>(['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<string, string | undefined> = process.env,
lightDefaultTermPrograms: ReadonlySet<string> = 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<string, string | undefined> = 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<string, string | undefined> = 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<string, string>,
branding: Record<string, string>,
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 ?? ''
)
}

View file

@ -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<void> {
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()

View file

@ -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<typeof 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' })
}
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(() => <App store={store} />, { width: 60, height: 8 })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ 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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ width: 60, height: 8 }
)
expect(frame).toContain('Zephyr')
expect(frame).not.toContain('Hermes Agent')
})
})

View file

@ -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)
})
})

View file

@ -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!')
})
})

View file

@ -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 <b>/<span> children, never an attributes bitmask (gotcha §8 #1).
* Inline color goes in `style={{ fg }}` on <span>; <text> accepts `fg` directly
* (verified against @opentui/solid@0.3.2 SpanProps/TextProps + opencode usage).
* Inline color goes in `style={{ fg }}` on <span>; <text> 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 (
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
<box style={{ flexShrink: 0 }}>
<text>
<b>hermes</b>
<span> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: '#888888' }}>connecting</span>}>
<span style={{ fg: '#8BD5CA' }}>ready</span>
<b>{theme().brand.name}</b>
<span style={{ fg: theme().color.muted }}> · opentui · </span>
<Show when={props.store.state.ready} fallback={<span style={{ fg: theme().color.muted }}>connecting</span>}>
<span style={{ fg: theme().color.ok }}>ready</span>
</Show>
</text>
</box>
@ -35,12 +33,12 @@ export function App(props: AppProps) {
<For each={props.store.state.messages}>
{message => (
<text>
<span style={{ fg: message.role === 'assistant' ? '#8BD5CA' : '#C6A0F6' }}>
{message.role === 'assistant' ? '✦ ' : '> '}
<span style={{ fg: message.role === 'assistant' ? theme().color.accent : theme().color.prompt }}>
{message.role === 'assistant' ? `${theme().brand.icon} ` : `${theme().brand.prompt} `}
</span>
<span>{message.text}</span>
<span style={{ fg: theme().color.text }}>{message.text}</span>
<Show when={message.streaming}>
<span style={{ fg: '#888888' }}></span>
<span style={{ fg: theme().color.muted }}></span>
</Show>
</text>
)}

View file

@ -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<Accessor<Theme>>(() => DEFAULT_THEME)
export interface ThemeProviderProps {
/** Reactive theme accessor (from the store). Defaults to DEFAULT_THEME if omitted. */
readonly theme?: Accessor<Theme>
readonly children: JSX.Element
}
export function ThemeProvider(props: ThemeProviderProps) {
return <ThemeContext.Provider value={props.theme ?? (() => DEFAULT_THEME)}>{props.children}</ThemeContext.Provider>
}
/** Read the current theme inside a component. Call it (`useTheme()()`) to get the Theme. */
export function useTheme(): Accessor<Theme> {
return useContext(ThemeContext)
}