mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
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:
parent
12342a4bce
commit
cc2c881fd1
15 changed files with 1706 additions and 95 deletions
228
ui-tui-opentui-v2/src/boundary/gateway/client.ts
Normal file
228
ui-tui-opentui-v2/src/boundary/gateway/client.ts
Normal 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
|
||||
}
|
||||
}
|
||||
129
ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts
Normal file
129
ui-tui-opentui-v2/src/boundary/gateway/liveGateway.ts
Normal 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)
|
||||
)
|
||||
)
|
||||
39
ui-tui-opentui-v2/src/boundary/gateway/python.ts
Normal file
39
ui-tui-opentui-v2/src/boundary/gateway/python.ts
Normal 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, '../../../../')
|
||||
}
|
||||
162
ui-tui-opentui-v2/src/boundary/log.ts
Normal file
162
ui-tui-opentui-v2/src/boundary/log.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
493
ui-tui-opentui-v2/src/logic/theme.ts
Normal file
493
ui-tui-opentui-v2/src/logic/theme.ts
Normal 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 ?? ''
|
||||
)
|
||||
}
|
||||
68
ui-tui-opentui-v2/src/test/liveGateway.smoke.ts
Normal file
68
ui-tui-opentui-v2/src/test/liveGateway.smoke.ts
Normal 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()
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
48
ui-tui-opentui-v2/src/test/schema.test.ts
Normal file
48
ui-tui-opentui-v2/src/test/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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!')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
29
ui-tui-opentui-v2/src/view/theme.tsx
Normal file
29
ui-tui-opentui-v2/src/view/theme.tsx
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue