feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui

Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
This commit is contained in:
alt-glitch
2026-06-09 16:16:48 +00:00
parent 25567919ea
commit ae11a636dc
85 changed files with 5613 additions and 900 deletions
+126
View File
@@ -0,0 +1,126 @@
/**
* Clipboard (item 1) — copy via OSC 52 (works over SSH/tmux) + a native platform
* command, and read a clipboard IMAGE for paste-to-attach. Ported/trimmed from
* opencode `clipboard.ts`. A boundary concern (spawns processes / writes stdout);
* everything is best-effort and never throws into the view.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { platform } from 'node:os'
import { join } from 'node:path'
/** Whether `cmd` resolves on PATH (cached). We DON'T spawn missing tools: a failed
* spawn + writing to its dead stdin pipe raises EPIPE/SIGPIPE, and OpenTUI used to
* treat SIGPIPE as a shutdown signal — i.e. a clipboard miss would quit the TUI.
* Skipped on Windows (the built-in `clip` is always present; PATHEXT complicates
* a filename probe). */
const commandCache = new Map<string, boolean>()
function commandExists(cmd: string): boolean {
if (platform() === 'win32') return true
const cached = commandCache.get(cmd)
if (cached !== undefined) return cached
const dirs = (process.env.PATH ?? '').split(':').filter(Boolean)
const found = dirs.some(dir => existsSync(join(dir, cmd)))
commandCache.set(cmd, found)
return found
}
/** Run a command, optionally piping `input` to stdin; resolve its stdout bytes.
* Best-effort and crash-proof: every stream error (incl. EPIPE → SIGPIPE on a
* clipboard tool that exits early) is swallowed so a failed copy never throws out
* of the boundary or signals the process. */
function run(cmd: string, args: string[] = [], input?: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
let child
try {
child = spawn(cmd, args, { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'] })
} catch (cause) {
reject(cause instanceof Error ? cause : new Error(String(cause)))
return
}
const out: Buffer[] = []
child.on('error', reject)
child.stdout?.on('error', () => {}) // a closed stdout pipe must not throw
child.stdout?.on('data', (c: Buffer) => out.push(c))
child.on('close', code => (code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`${cmd} exit ${code}`))))
if (input !== undefined && child.stdin) {
// Writing to a tool that died/closed early raises EPIPE (→ SIGPIPE). Swallow it.
child.stdin.on('error', () => {})
try {
child.stdin.end(input)
} catch {
// pipe already gone — nothing to flush
}
}
})
}
/** OSC 52 copy — the terminal puts `text` on the system clipboard (SSH/tmux-safe). */
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const seq = `\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`
// tmux/screen need the sequence wrapped in their passthrough escape.
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${seq}\x1b\\` : seq)
}
/** Native copy commands to try, in order, for the current platform. */
function copyCandidates(): Array<[string, string[]]> {
const os = platform()
if (os === 'darwin') return [['pbcopy', []]]
if (os === 'win32') return [['clip', []]]
// linux: prefer Wayland, then X11 tools
const list: Array<[string, string[]]> = []
if (process.env.WAYLAND_DISPLAY) list.push(['wl-copy', []])
list.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']])
return list
}
/** Copy `text` to the clipboard: OSC 52 (always) + the first native command that works. */
export async function writeClipboard(text: string): Promise<void> {
writeOsc52(text) // primary path — SSH/tmux-safe, no subprocess
for (const [cmd, args] of copyCandidates()) {
if (!commandExists(cmd)) continue // never spawn a missing tool (avoids EPIPE/SIGPIPE)
try {
await run(cmd, args, text)
return
} catch {
// try the next candidate
}
}
}
/** Read a clipboard IMAGE as base64 PNG (for paste-to-attach); undefined if none. */
export async function readClipboardImage(): Promise<{ data: string; mime: string } | undefined> {
const os = platform()
const tries: Array<[string, string[]]> = []
if (os === 'linux') {
if (process.env.WAYLAND_DISPLAY) tries.push(['wl-paste', ['-t', 'image/png']])
tries.push(['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']])
} else if (os === 'darwin') {
tries.push(['pngpaste', ['-']]) // brew install pngpaste
} else if (os === 'win32') {
tries.push([
'powershell.exe',
[
'-NonInteractive',
'-NoProfile',
'-Command',
'Add-Type -AssemblyName System.Windows.Forms; $img=[System.Windows.Forms.Clipboard]::GetImage(); if($img){$ms=New-Object System.IO.MemoryStream; $img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))}'
]
])
}
for (const [cmd, args] of tries) {
if (!commandExists(cmd)) continue // skip missing tools (no pointless failing spawns)
try {
const buf = await run(cmd, args)
if (buf.length) {
// powershell already returns base64 text; the others return raw PNG bytes.
const data = os === 'win32' ? buf.toString('utf8').trim() : buf.toString('base64')
if (data) return { data, mime: 'image/png' }
}
} catch {
// try the next candidate
}
}
return undefined
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Typed errors at the gateway boundary.
*
* Per spec v4 §3.4: internal errors use `Data.TaggedError`; wire/serializable
* errors use Schema-based tagged errors (added in Phase 1 alongside the
* GatewayEvent schema). Phase 0 ships the internal set the renderer/transport
* boundary needs.
*
* Boundary code yields these directly (`return yield* new FooError(...)`) — no
* throw / try-catch / Promise.catch / orDie.
*/
import { Data } from 'effect'
/** The renderer (createCliRenderer) failed to acquire. */
export class RendererError extends Data.TaggedError('RendererError')<{
readonly cause: unknown
}> {}
/** Could not resolve a usable Python interpreter for the gateway. */
export class PythonResolutionError extends Data.TaggedError('PythonResolutionError')<{
readonly tried: ReadonlyArray<string>
}> {}
/** A JSON-RPC request to the gateway failed (timeout, transport down, rpc error). */
export class GatewayError extends Data.TaggedError('GatewayError')<{
readonly method: string
readonly reason: 'timeout' | 'transport-down' | 'rpc-error'
readonly message: string
}> {}
@@ -0,0 +1,29 @@
/**
* GatewayService — the Effect-side transport boundary.
*
* Phase 0: the SHAPE only. The live layer (spawning the Python `tui_gateway`,
* JSON-RPC framing, Schema-decoding the wire union) lands in Phase 1
* (`boundary/gateway/liveGateway.ts`). For now the only implementation is
* `FakeGateway.layer` (entry/fakeGateway.ts), which the render/test harness uses.
*
* This is one of exactly two Effect<->Solid contact points: the Solid store
* subscribes via `subscribe(handler)` and the boundary pushes DECODED events in.
* Per spec v4 §1, the store/reducer themselves are plain Solid, never Effect.
*/
import { Context, type Effect } from 'effect'
import type { GatewayError } from '../errors.ts'
import type { GatewayEvent } from '../schema/GatewayEvent.ts'
export interface GatewayServiceShape {
/** Push decoded gateway events into the Solid store. Returns an unsubscribe fn. */
readonly subscribe: (handler: (event: GatewayEvent) => void) => Effect.Effect<() => void>
/** Typed JSON-RPC request to the Python gateway. Fails with a typed GatewayError, never throws. */
readonly request: <A>(method: string, params: unknown) => Effect.Effect<A, GatewayError>
/** The active session id (for `approval.respond {session_id}`); undefined before a session exists. */
readonly sessionId: () => string | undefined
}
export class GatewayService extends Context.Service<GatewayService, GatewayServiceShape>()(
'@hermes-tui/GatewayService'
) {}
+255
View File
@@ -0,0 +1,255 @@
/**
* 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 { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
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 = (() => {
const raw = Number.parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '', 10)
return Number.isFinite(raw) && raw > 0 ? Math.max(5000, raw) : 120_000
})()
const STARTUP_TIMEOUT_MS = (() => {
const raw = Number.parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '', 10)
return Number.isFinite(raw) && raw > 0 ? Math.max(2000, raw) : 20_000
})()
export class RawGatewayClient {
private proc: ChildProcessWithoutNullStreams | null = null
private pending = new Map<string, Pending>()
private reqId = 0
private stdinBuffer = ''
private startupTimer: ReturnType<typeof setTimeout> | undefined
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 = spawn(python, ['-m', 'tui_gateway.entry'], {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe']
})
// Identity guard: a stale child's late exit/error must not act after a restart
// has already installed a new `this.proc` (else it'd null the live child).
// Nulling `this.proc` here makes a subsequent finish() a no-op (idempotent),
// covering the ENOENT case where 'error' fires and 'exit' does not.
const finish = (reason: string) => {
if (this.proc !== proc) return
this.log.warn('gateway', reason)
this.rejectAll(reason)
this.proc = null
this.onExit?.(reason)
}
proc.on('exit', (code, signal) => finish(`gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`))
proc.on('error', err => finish(`gateway spawn error: ${err instanceof Error ? err.message : String(err)}`))
this.proc = proc
this.readStdout(proc)
this.readStderr(proc)
// Startup-readiness watchdog: a child that hangs on import (wrong python /
// missing dep) never emits the unsolicited `gateway.ready` handshake, leaving
// a silent blank UI. Emit `gateway.start_timeout` so the store can surface a
// failure line + the captured stderr tail. Cleared on ready (dispatch) / stop.
// A recovery-respawn re-enters start(), so this re-arms per respawn — desired.
this.startupTimer = setTimeout(() => {
this.startupTimer = undefined
this.onEvent({
type: 'gateway.start_timeout',
payload: { message: `no gateway.ready within ${STARTUP_TIMEOUT_MS}ms` }
})
}, STARTUP_TIMEOUT_MS)
}
private readStdout(proc: ChildProcessWithoutNullStreams): void {
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => {
this.stdinBuffer += chunk
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)
}
})
proc.stdout.on('error', cause => this.log.error('gateway', 'stdout read loop failed', { cause: String(cause) }))
}
private readStderr(proc: ChildProcessWithoutNullStreams): void {
let buf = ''
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => {
buf += chunk
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 } })
}
}
})
// stderr pipe closing on exit is expected; ignore errors.
proc.stderr.on('error', () => {})
}
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.
const pending = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined
if (typeof frame.id === 'string' && pending) {
const p = pending
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') {
// Handshake arrived: cancel the startup-readiness watchdog. Narrow without
// `as` via `'type' in obj` + property access (the params record is loose).
if ('type' in frame.params && frame.params.type === 'gateway.ready') {
if (this.startupTimer) clearTimeout(this.startupTimer)
this.startupTimer = undefined
}
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> {
// Do NOT auto-start here: during the recovery backoff window `this.proc` is
// null, and a respawn here would BYPASS the backoff (the first spawn always
// comes from subscribe() → client.start()). A null proc rejects below.
const proc = this.proc
const stdin = proc?.stdin
if (!stdin) 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 {
// Newline-delimited JSON to the child's stdin. Fire-and-forget: the write
// returns a backpressure boolean we intentionally ignore (frames are tiny
// and ordered; Node flushes the pipe itself).
stdin.write(frame)
} 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 {
if (this.startupTimer) clearTimeout(this.startupTimer)
this.startupTimer = undefined
this.rejectAll('gateway stopping')
const stdin = this.proc?.stdin
if (stdin) {
try {
// Close stdin → child sees EOF and exits.
stdin.end()
} catch {
// already gone
}
}
this.proc = null
}
}
@@ -0,0 +1,175 @@
/**
* 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 { backoffMs, planGatewayRecovery } from '../../logic/gatewayRecovery.ts'
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
// Auto-heal recovery state (driver below). `recoverSid` is the resume target
// carried across a respawn that died before gateway.ready; `recoveryAttempts`
// is the sliding crash-loop budget window; `restartTimer` is the pending
// backoff respawn (cleared on teardown so it can't fire post-stop).
let recoverSid: string | undefined
let recoveryAttempts: number[] = []
let restartTimer: ReturnType<typeof setTimeout> | 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)
}
// Recovery driver: on a child exit, clear the frozen spinner (via the store's
// gateway.exited case), then — under the crash-loop budget — respawn the child
// on exponential backoff. The post-respawn gateway.ready triggers the re-resume
// (driven from entry's subscribe callback). Hoisted so it can be passed to
// `new RawGatewayClient` below while itself referencing the `client` const —
// `client` is assigned by the time onExit ever fires at runtime.
function onExit(reason: string): void {
log.warn('gateway', 'transport exited', { reason })
// Clears the frozen spinner + shows status (store handles gateway.exited).
enqueue({ type: 'gateway.exited', payload: { reason } })
const plan = planGatewayRecovery(sessionId ?? null, recoverSid ?? null, recoveryAttempts, Date.now())
recoveryAttempts = plan.attempts
if (!plan.recover || plan.sid === null) {
enqueue({ type: 'error', payload: { message: 'gateway exited repeatedly — type /resume to retry' } })
return
}
recoverSid = plan.sid
const attempt = recoveryAttempts.length
const delay = backoffMs(attempt)
enqueue({ type: 'gateway.recovering', payload: { attempt, delay_ms: delay } })
if (restartTimer) clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
restartTimer = undefined
client.start()
}, delay)
}
const client = new RawGatewayClient({
log,
onEvent: onRawEvent,
onExit
})
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
}
// Clear a pending coalesce timer on teardown so a queued flush() can't fire
// batch()/handlers into a torn-down store after the layer scope releases.
const stop = () => {
if (timer) clearTimeout(timer)
timer = undefined
// Also kill any pending backoff respawn so it can't fire after teardown.
if (restartTimer) clearTimeout(restartTimer)
restartTimer = undefined
client.stop()
}
return { service, 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)
)
)
+49
View File
@@ -0,0 +1,49 @@
/**
* 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 { dirname, 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 (no launcher env): walk up from this module to the Hermes checkout
// root — the dir holding the `hermes_cli` package / `pyproject.toml`. Bundle-
// agnostic, so it works whether running the source tree (.../src/boundary/gateway)
// or the built `dist/main.js`. (Under the real launcher this never runs — the
// launcher always sets HERMES_PYTHON_SRC_ROOT.)
let dir = import.meta.dirname
for (let i = 0; i < 8; i++) {
if (existsSync(resolve(dir, 'hermes_cli')) || existsSync(resolve(dir, 'pyproject.toml'))) return dir
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return resolve(import.meta.dirname, '../../../../')
}
+248
View File
@@ -0,0 +1,248 @@
/**
* 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, renameSync, statSync, unlinkSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { Schema } from 'effect'
// LogLevel is modeled schema-first (the schema-inferred-types idiom, mirroring
// `boundary/schema/GatewayEvent.ts`): declare the literal union once and INFER
// the TS type from it, so the two can never drift.
export const LogLevelSchema = Schema.Literals(['debug', 'info', 'warn', 'error'])
export type LogLevel = typeof LogLevelSchema.Type
const PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }
/**
* Serialize a value to JSON that NEVER throws. A caller-supplied `data` can hold
* a circular reference or a BigInt — plain `JSON.stringify` throws on both, which
* (in the file-write `catch` below) would flip `fileBroken` and kill ALL file
* logging for the session. Instead we degrade a bad payload to a placeholder:
* - circular refs (tracked via a per-call `WeakSet` of seen objects) → '[Circular]'
* - BigInt → `\`${n}n\`` (JSON has no bigint; keep it readable + reversible-ish)
* and wrap the whole thing so any other throw (e.g. a hostile `toJSON`) falls back
* to `String(value)`, then to '[unserializable]' if even that throws.
*/
export function safeStringify(value: unknown): string {
try {
const seen = new WeakSet<object>()
return JSON.stringify(value, (_key, val: unknown) => {
if (typeof val === 'bigint') return `${val}n`
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]'
seen.add(val)
}
return val
})
} catch {
try {
return String(value)
} catch {
return '[unserializable]'
}
}
}
export interface LogEntry {
readonly t: number // epoch ms
readonly level: LogLevel
readonly scope: string
readonly msg: string
readonly data?: unknown
}
const RING_LIMIT = 2000
// Size-based rotation for the append-only NDJSON file (mirrors opencode's
// keep-N model, but size- rather than time-keyed since we write one growing
// file). When the live file crosses LOG_MAX_BYTES we shift
// `.log` → `.log.1` → … → `.log.${LOG_KEEP}` (dropping the oldest) and resume on
// a fresh empty `.log`. Rotation is best-effort: any failure leaves us writing
// to the existing file (logging must never crash the engine).
const LOG_MAX_BYTES = 5 * 1024 * 1024
const LOG_KEEP = 5
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
// Bytes in the live log file. Seeded from statSync on open (counter approach —
// we avoid a statSync on EVERY write); incremented by each line's byte length
// and reset to 0 after a rotation. Rotation triggers when this would cross
// LOG_MAX_BYTES, so the live file stays bounded without per-write fs stats.
private fileBytes = 0
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
}
try {
this.fileBytes = statSync(this.file).size
} catch {
this.fileBytes = 0 // no existing file (or unreadable) → start the counter at 0
}
}
}
setLevel(level: LogLevel): void {
this.minPriority = PRIORITY[level]
}
/**
* Best-effort size-based rotation: `.log.${LOG_KEEP}` is dropped, every other
* `.log.N` shifts up, the live `.log` becomes `.log.1`, and the counter resets
* so writing continues on a fresh file. Any fs failure is swallowed and we keep
* writing to the existing file — rotation must never crash logging.
*/
private rotate(file: string): void {
try {
try {
unlinkSync(`${file}.${LOG_KEEP}`)
} catch {
// oldest slot may not exist yet — fine
}
for (let i = LOG_KEEP - 1; i >= 1; i--) {
try {
renameSync(`${file}.${i}`, `${file}.${i + 1}`)
} catch {
// that slot may not exist yet — fine
}
}
renameSync(file, `${file}.1`)
this.fileBytes = 0
} catch {
// rotation failed (e.g. live file vanished) — leave the counter alone and
// keep appending to the existing path; better an oversized log than none.
}
}
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 {
const line = safeStringify(entry) + '\n'
if (this.fileBytes > 0 && this.fileBytes + Buffer.byteLength(line) > LOG_MAX_BYTES) this.rotate(this.file)
appendFileSync(this.file, line)
this.fileBytes += Buffer.byteLength(line)
} 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
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Renderer lifecycle — the Effect-side resource boundary (spec v4 §3.1).
*
* `acquireRelease(createCliRenderer)` so the renderer is always destroyed on
* scope exit; a `Deferred` resolved on the renderer's "destroy" event lets the
* entry block until the user quits. Mirrors opencode `app.tsx:177` /
* `:185-225`.
*
* No throw / try-catch here: acquisition failure surfaces as a typed
* `RendererError` via `Effect.tryPromise`'s `catch`.
*/
import { createCliRenderer, type CliRenderer, type KeyEvent, type Selection } from '@opentui/core'
import { Deferred, Effect } from 'effect'
import { RendererError } from './errors.ts'
import { getLog } from './log.ts'
/**
* The text a finished selection copies: the RENDERED text the user highlighted,
* verbatim (`getSelectedText()` does correct same-line merging). Markdown markers
* are concealed in the pretty render, so a partial selection cannot recover source —
* this copies exactly what was highlighted (the `/copy` command gives full source).
* Total by construction — a copy must NEVER throw out of an input/event handler
* (that would tear down the render loop).
*/
function selectionCopyText(selection: Selection): string {
try {
return selection.getSelectedText()
} catch (cause) {
getLog().warn('copy', 'getSelectedText failed', { cause: String(cause) })
return ''
}
}
export interface RendererOptions {
/** Mouse tracking on/off (from decoded display config). */
readonly mouse: boolean
/** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */
readonly isBlocked?: () => boolean
/**
* Ctrl+C handler (item 11). When set, it OWNS Ctrl+C while not blocked — the
* entry's state machine decides interrupt-the-turn vs quit. When omitted, the
* default is an immediate `renderer.destroy()` (quit).
*/
readonly onCtrlC?: () => void
/**
* Copy a mouse selection (item 1). When there's a live selection, Ctrl+C copies
* it (this callback) instead of interrupting/quitting — opencode's selection
* key precedence (`app.tsx:388`). Receives the rendered text the user highlighted.
*/
readonly onCopySelection?: (text: string) => void
}
/**
* Acquire a CliRenderer inside the current scope and register its release.
* Returns the renderer plus a Deferred that resolves when the renderer is
* destroyed (user quit) — `await` it to keep the entry alive.
*/
export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: RendererOptions) {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise({
try: () =>
createCliRenderer({
// scrollbox clips growing output → no terminal-scrollback corruption (gotcha §8 #2).
externalOutputMode: 'passthrough',
targetFps: 60,
// prompts own Ctrl+C → deny/cancel (gotcha §8 #6); the global quit is gated on !blocked.
exitOnCtrlC: false,
// OpenTUI's default exitSignals include SIGPIPE + SIGBUS, and its handler
// calls renderer.destroy() — so a broken clipboard pipe (writeClipboard
// spawning xclip/wl-copy that dies) raises SIGPIPE and QUITS THE TUI on
// copy. SIGPIPE/SIGBUS are not shutdown intents; restrict to the genuine
// termination signals so a stray pipe error can never tear down the UI.
exitSignals: ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'],
useKittyKeyboard: {},
useMouse: options.mouse
}),
catch: cause => new RendererError({ cause })
}),
renderer => Effect.sync(() => destroyRenderer(renderer))
)
const shutdown = yield* Deferred.make<void>()
renderer.once('destroy', () => {
Deferred.doneUnsafe(shutdown, Effect.void)
})
// Global quit on Ctrl+C. `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). When a
// blocking prompt is up, it owns Ctrl+C (→ deny/cancel) so we suppress the quit
// (gotcha §8 #6) — the prompt's own handler sends the cancel reply.
const isBlocked = options.isBlocked ?? (() => false)
renderer.keyInput.on('keypress', (key: KeyEvent) => {
if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return
// Copy a live mouse selection first (item 1) — takes precedence over the
// interrupt/quit machine and over a blocking prompt's cancel.
if (options.onCopySelection) {
const selection = renderer.getSelection()
const text = selection ? selectionCopyText(selection) : ''
if (text) {
options.onCopySelection(text)
renderer.clearSelection()
return
}
}
if (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel)
if (options.onCtrlC) options.onCtrlC()
else renderer.destroy()
})
// Copy-on-select (item 1 parity with free-code/Ink): the renderer's "selection"
// event fires ONCE when a free-form mouse selection COMPLETES (drag finish);
// auto-copy the spanned selectable text. Unlike the Ctrl+C path above we do NOT
// clearSelection() — the highlight persists so the user sees what was copied and
// Ctrl+C still works on it. `writeClipboard` is idempotent, so both paths writing
// the same text is harmless (no double-write bug). `CliRenderer extends
// EventEmitter`, so `on('selection', …)` is untyped → annotate `selection`.
const onCopy = options.onCopySelection
if (onCopy) {
renderer.on('selection', (selection: Selection) => {
const text = selectionCopyText(selection)
if (text) onCopy(text)
})
}
return { renderer, shutdown } as const
})
/** Best-effort renderer teardown; never throws out of the finalizer. */
function destroyRenderer(renderer: CliRenderer): void {
try {
if (!renderer.isDestroyed) renderer.destroy()
} catch {
// teardown is best-effort; a failed destroy must not mask the real exit cause.
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Runtime composition — the single edge where layers are provided and the
* program is run (spec v4 §3.1). Layers are provided HERE by the caller
* (the launcher entry), never inside components. Mirrors opencode
* `cli/tui/layer.ts:6` + `cli/cmd/tui.ts` runMain.
*/
import { Layer } from 'effect'
import type { GatewayService } from './gateway/GatewayService.ts'
/**
* The application layer. Phase 0 takes the GatewayService layer as a parameter
* so the entry can choose Fake (dev/test) or — from Phase 1 — the live
* `tui_gateway`-spawning layer. Compose additional boundary services
* (Config, Theme-with-IO) here as they land.
*/
export const makeAppLayer = (gateway: Layer.Layer<GatewayService>) => Layer.mergeAll(gateway)
@@ -0,0 +1,254 @@
/**
* 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`).
*
* 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'
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),
// `usage` carries the post-turn token/context totals → refreshes the status bar
// (item 14). Kept loose (Record) — the chrome reader narrows what it needs.
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str), usage: opt(Schema.Record(Str, Schema.Unknown)) }))
})
// 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) }))
})
// gateway lifecycle recovery (auto-heal): the child exited (crash/kill) and the
// transport is respawning+resuming the session. Surfaced so the frozen spinner
// clears and the user sees the in-flight reply was lost (see store cases).
const GatewayExited = Schema.Struct({
type: Schema.Literal('gateway.exited'),
session_id: opt(Str),
payload: opt(Schema.Struct({ reason: opt(Str), code: opt(Schema.Number), signal: opt(Str) }))
})
const GatewayRecovering = Schema.Struct({
type: Schema.Literal('gateway.recovering'),
session_id: opt(Str),
payload: opt(Schema.Struct({ attempt: opt(Schema.Number), delay_ms: opt(Schema.Number) }))
})
// ── 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,
GatewayExited,
GatewayRecovering
]).pipe(Schema.toTaggedUnion('type'))
/** The decoded, typed event. Inferred from the schema — never hand-declared. */
export type GatewayEvent = typeof GatewayEventSchema.Type
@@ -0,0 +1,99 @@
/**
* SessionInfo + Catalog decoders — the decode-at-boundary idiom (spec v4 §3.3),
* mirroring GatewayEvent.ts. These two payloads are UNTRUSTED loose JSON from the
* Python `tui_gateway` (`session.info` event / `session.create`/`resume` result
* `info`, and the `startup.catalog` RPC result), so they are decoded ONCE with an
* Effect Schema instead of hand-rolled `as`-cast readers.
*
* Decode with `Schema.decodeUnknownOption`: a malformed/partial payload yields
* `Option.none` and the caller falls back to an empty patch / leaves the catalog
* unset — a stray shape never crashes the reducer.
*
* Wire field names are verified against `tui_gateway/server.py`:
* - session.info → `_session_info()` (server.py:~1830): top-level `model`,
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested
* `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`,
* `context_max`, `context_percent`, `compressions` (context_* only present
* when the compressor knows a context length).
* - startup.catalog → `@method("startup.catalog")` (server.py:~8521):
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
*
* These schemas are used PURELY as decoders; they do NOT Effect-ify the store's
* reactivity or control flow (Solid stays the runtime — spec v4 §1).
*/
import { Schema } from 'effect'
const Str = Schema.String
const Num = Schema.Number
const Bool = Schema.Boolean
const opt = Schema.optionalKey
// ── session.info / session.create.info ────────────────────────────────
// Context/usage numbers arrive nested under `usage`; the same names may also
// appear at the top level depending on the RPC vs event path (the reader prefers
// `usage.context_*`, then the top-level fallback). All keys are optional — a
// `session.info` patch only carries the fields that actually changed.
const UsageSchema = Schema.Struct({
context_used: opt(Num),
context_max: opt(Num),
context_percent: opt(Num),
compressions: opt(Num)
})
export const SessionInfoPatchSchema = Schema.Struct({
model: opt(Str),
reasoning_effort: opt(Str),
fast: opt(Bool),
cwd: opt(Str),
branch: opt(Str),
running: opt(Bool),
// top-level context fallback (used when there's no nested `usage`)
context_used: opt(Num),
context_max: opt(Num),
context_percent: opt(Num),
compressions: opt(Num),
usage: opt(UsageSchema)
})
export type SessionInfoPatchDecoded = typeof SessionInfoPatchSchema.Type
/** Decode a loose session.info payload → `Option<SessionInfoPatchDecoded>`. */
export const decodeSessionInfoPatch = Schema.decodeUnknownOption(SessionInfoPatchSchema)
// ── startup.catalog ───────────────────────────────────────────────────
// Mirrors the `Catalog` interface in store.ts. `enabled` defaults to true at the
// reader (an absent flag means on), so it stays optional here.
const ToolsetSchema = Schema.Struct({
name: opt(Str),
count: opt(Num),
enabled: opt(Bool),
tools: opt(Schema.Array(Schema.Unknown))
})
const CategorySchema = Schema.Struct({
name: opt(Str),
count: opt(Num)
})
export const CatalogSchema = Schema.Struct({
tools: opt(
Schema.Struct({
total: opt(Num),
toolsets: opt(Schema.Array(ToolsetSchema))
})
),
skills: opt(
Schema.Struct({
total: opt(Num),
categories: opt(Schema.Array(CategorySchema))
})
),
mcp: opt(
Schema.Struct({
servers: opt(Schema.Array(Schema.Unknown))
})
)
})
export type CatalogDecoded = typeof CatalogSchema.Type
/** Decode a loose startup.catalog result → `Option<CatalogDecoded>`. */
export const decodeCatalog = Schema.decodeUnknownOption(CatalogSchema)
+64
View File
@@ -0,0 +1,64 @@
/**
* FakeGateway — the test/dev implementation of GatewayService (spec v4 §2/§5
* Layer-3 seam). Provides an emittable event source and a spy `request`, so
* store/component tests can drive synthetic streams and assert RPC calls
* without spawning Python. Mirrors opencode's injectable fake transport.
*
* Phase 0 uses it to stream a scripted "hello" so the entry/test renders a
* non-empty frame. Phase 1 swaps in `liveGateway.layer` (real `tui_gateway`).
*/
import { Effect, Layer } from 'effect'
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts'
export interface FakeGatewayController {
readonly service: GatewayServiceShape
/** Emit a decoded event to all subscribers (drives the store in tests). */
readonly emit: (event: GatewayEvent) => void
/** Recorded (method, params) pairs from `request` calls. */
readonly calls: Array<{ method: string; params: unknown }>
}
/** Build a fresh fake controller (used directly in tests, or wrapped as a Layer). */
export function makeFakeGateway(initialSessionId = 'fake-session'): FakeGatewayController {
const handlers = new Set<(event: GatewayEvent) => void>()
const calls: Array<{ method: string; params: unknown }> = []
const service: GatewayServiceShape = {
subscribe: handler =>
Effect.sync(() => {
handlers.add(handler)
return () => {
handlers.delete(handler)
}
}),
request: <A>(method: string, params: unknown) =>
Effect.sync(() => {
calls.push({ method, params })
return undefined as A
}),
sessionId: () => initialSessionId
}
return {
service,
emit: event => {
for (const handler of handlers) handler(event)
},
calls
}
}
/** A GatewayService layer backed by a fresh FakeGateway. The controller is
* reachable for assertions via the returned tuple in tests; for the dev entry
* use {@link fakeGatewayLayer} and drive it from a scripted effect. */
export function fakeGatewayLayerWith(controller: FakeGatewayController): Layer.Layer<GatewayService> {
return Layer.succeed(GatewayService, controller.service)
}
/** Convenience: a layer + its controller, for the dev entry's scripted stream. */
export function makeFakeGatewayLayer(): { layer: Layer.Layer<GatewayService>; controller: FakeGatewayController } {
const controller = makeFakeGateway()
return { layer: Layer.succeed(GatewayService, controller.service), controller }
}
+492
View File
@@ -0,0 +1,492 @@
/**
* Entry — the single boundary edge (spec v4 §3.1). This is the ONE place that:
* - acquires the renderer (acquireRelease + Deferred-on-destroy),
* - 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)`).
*
* 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 { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
import { KeymapProvider } from '@opentui/keymap/solid'
import { render } from '@opentui/solid'
import { Deferred, Duration, Effect } from 'effect'
import { writeFileSync } from 'node:fs'
import { readClipboardImage, writeClipboard } from '../boundary/clipboard.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 { nthAssistantResponse } from '../logic/copy.ts'
import { envFlag } from '../logic/env.ts'
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
import { createPasteStore } from '../logic/pastes.ts'
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.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
/** Resume a session instead of creating one: a session id, or 'recent'/'last' (→ session.most_recent). */
readonly resumeId?: string
}
const READY_POLL = Duration.millis(100)
const READY_TIMEOUT_MS = 20_000
/** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */
const QUIT_WINDOW_MS = 3_000
/**
* Resume a session INTO the store: buffer live events across the `session.resume`
* RPC, then replace history + replay (gotcha §8 #5 tool rows handled by
* mapResumeHistory). Shared by the launch bootstrap and the session switcher.
* Timed (rpc_ms / hydrate_ms) for the resume profile.
*/
/**
* Record the CURRENT session id in `HERMES_TUI_ACTIVE_SESSION_FILE` (item #5).
* The launcher reads this on exit to print the right "Resume this session with…"
* epilogue (hermes_cli/main.py `_print_tui_exit_summary`). The Ink TUI writes it on
* every session change (useSessionLifecycle.writeActiveSessionFile); the native
* engine must too, or the launcher falls back to the INITIAL launch session and
* shows resume info for the wrong session after a `/session` switch.
*/
const writeActiveSession = (sid: string | undefined) => {
const file = process.env.HERMES_TUI_ACTIVE_SESSION_FILE
if (!file || !sid) return
try {
writeFileSync(file, JSON.stringify({ session_id: sid }), { mode: 0o600 })
} catch (cause) {
getLog().warn('bootstrap', 'active-session-file write failed', { cause: String(cause) })
}
}
const resumeInto = (gateway: GatewayServiceShape, store: SessionStore, sid: string, cols: number) =>
Effect.gen(function* () {
writeActiveSession(sid) // the session we're switching to is now the active one (#5)
store.setSessionId(sid)
store.beginBuffer()
const t0 = Date.now()
const resumed = yield* gateway.request<{ messages?: unknown; info?: Record<string, unknown> }>('session.resume', {
cols,
session_id: sid,
// native engine renders tools collapsed → safe to fold each tool's capped
// result into the resume snapshot so resumed turns render like live (item 1).
with_tool_output: true
})
const t1 = Date.now()
const snapshot = mapResumeHistory(resumed?.messages)
store.commitSnapshot(snapshot)
if (resumed?.info) store.applyInfo(resumed.info)
getLog().info('bootstrap', 'session resumed', {
count: snapshot.length,
hydrate_ms: Date.now() - t1,
rpc_ms: t1 - t0,
sid
})
})
/**
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
* then either RESUME a session (hydrate its transcript — incl. tool rows — via
* the snapshot, buffering live events across the RPC) or CREATE a fresh one, and
* (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 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
}
let sid: string | undefined
if (input.resumeId) {
sid = input.resumeId
if (sid === 'recent' || sid === 'last') {
const recent = yield* gateway.request<{ session_id?: string }>('session.most_recent', {})
sid = recent?.session_id
}
if (!sid) {
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
return
}
yield* resumeInto(gateway, store, sid, input.cols)
} else {
const created = yield* gateway.request<{ session_id?: string; info?: Record<string, unknown> }>(
'session.create',
{ cols: input.cols }
)
sid = created?.session_id ?? gateway.sessionId()
if (!sid) {
log.warn('bootstrap', 'session.create returned no session_id')
return
}
if (created?.info) store.applyInfo(created.info)
writeActiveSession(sid) // record the new session for the launcher's exit epilogue (#5)
store.setSessionId(sid)
log.info('bootstrap', 'session created', { sid })
}
// Tools/skills/MCP catalog for the home-screen panel (item 9) — best-effort,
// never blocks startup if the RPC is missing/old.
const catalog = yield* gateway
.request<unknown>('startup.catalog', { session_id: sid })
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
if (catalog) store.setCatalog(catalog)
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(
Effect.gen(function* () {
// Solid side: the store + reducer. Created here, lives in Solid-land.
const store = createSessionStore()
// Prompt history (item 6): scoped to the launch directory so prior prompts
// from the same project dir are recallable (Up/Down), without bleeding
// across different dirs. process.cwd() is the user's launch dir under the
// real launcher.
const historyCwd = process.cwd()
const history = createPromptHistory({
initial: loadDirHistory(historyCwd),
persist: dirHistoryPersister(historyCwd)
})
// Pasted-text store — created ONCE here so it survives the composer
// remounting (overlay open/close); a per-composer store would lose a
// pending `[Pasted text #N]` mid-compose and submit would send it literally.
const pasteStore = createPasteStore()
// Contact point #2: boundary pushes decoded events into the Solid store.
// The callback ALSO drives auto-heal re-resume: a post-crash gateway.ready
// (i.e. one that follows a gateway.exited, so `recoverSid` is set) re-resumes
// the session so the transcript continues. The INITIAL gateway.ready has
// `recoverSid === undefined`, so the normal bootstrap path is untouched.
const gateway = yield* GatewayService
let recoverSid: string | undefined
yield* gateway.subscribe(event => {
store.apply(event)
if (event.type === 'gateway.exited') {
recoverSid = gateway.sessionId() ?? recoverSid
} else if (event.type === 'gateway.ready' && recoverSid !== undefined) {
const sid = recoverSid
recoverSid = undefined
Effect.runFork(
resumeInto(gateway, store, sid, input.cols).pipe(
Effect.catchCause(cause =>
Effect.sync(() => getLog().warn('recover', 'resume failed', { cause: String(cause) }))
)
)
)
}
})
// ── Ctrl+C state machine (item 11) ──────────────────────────────────
// While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt);
// a second Ctrl+C within QUIT_WINDOW_MS (or when idle) KILLS the TUI. The
// debounce stops a stray Ctrl+C from nuking the session (opencode's
// double-press model; the user's preferred behaviour).
let quitArmed = false
let quitTimer: ReturnType<typeof setTimeout> | undefined
let doQuit = () => {} // assigned once the renderer exists
const disarmQuit = () => {
quitArmed = false
if (quitTimer) clearTimeout(quitTimer)
quitTimer = undefined
store.setHint(undefined)
}
const armQuit = (message: string) => {
quitArmed = true
store.setHint(message)
if (quitTimer) clearTimeout(quitTimer)
quitTimer = setTimeout(disarmQuit, QUIT_WINDOW_MS)
}
const interruptTurn = () => {
const sid = gateway.sessionId()
if (!sid) return
Effect.runFork(
gateway
.request('session.interrupt', { session_id: sid })
.pipe(
Effect.catchCause(cause =>
Effect.sync(() => getLog().warn('interrupt', 'failed', { cause: String(cause) }))
)
)
)
}
const onCtrlC = () => {
if (quitArmed) {
disarmQuit()
doQuit()
return
}
if (store.state.info.running) {
interruptTurn()
armQuit('⏹ stopped — Ctrl+C again to quit')
} else {
armQuit('Ctrl+C again to quit')
}
}
// Transient hint that auto-clears (used by copy/image-paste feedback).
const flashHint = (message: string, ms = 1500) => {
store.setHint(message)
setTimeout(() => {
if (store.state.hint === message) store.setHint(undefined)
}, ms)
}
// Copy a mouse selection to the clipboard (item 1) — OSC 52 + native command.
// Copies exactly the rendered text the user highlighted (markers are concealed
// in the pretty render; the `/copy` command copies a full response's source).
const onCopySelection = (text: string) => {
void writeClipboard(text)
flashHint('Copied selection')
}
// Paste an IMAGE (item 1): read the clipboard image and attach it to the
// session (image.attach_bytes); the next prompt.submit picks it up.
const onImagePaste = () => {
void (async () => {
const img = await readClipboardImage()
if (!img) {
flashHint('No image in clipboard', 2000)
return
}
const sid = gateway.sessionId()
if (!sid) {
flashHint('No session for image', 2000)
return
}
try {
await Effect.runPromise(
gateway.request('image.attach_bytes', {
content_base64: img.data,
filename: 'pasted.png',
session_id: sid
})
)
flashHint('🖼 image attached — type a message and send', 3000)
} catch {
flashHint('Image attach failed', 2000)
}
})()
}
// A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs.
const { renderer, shutdown } = yield* acquireRenderer({
mouse: input.mouse,
isBlocked: () => store.state.prompt !== undefined,
onCtrlC,
onCopySelection
})
doQuit = () => {
if (!renderer.isDestroyed) renderer.destroy()
}
// Native keymap host (Phase 3): one keymap bound to this renderer, provided
// to the whole Solid tree via <KeymapProvider>. Overlays/prompts register
// close (and confirm) layers against it through useCloseLayer/useBindings.
const keymap = createDefaultOpenTuiKeymap(renderer)
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
// is Effect<…, never> — fire it detached with runFork; failures are logged.
const submitPrompt = (text: string) => {
store.pushUser(text)
const sid = gateway.sessionId()
if (!sid) {
getLog().warn('submit', 'no session yet — dropping prompt', { text })
return
}
Effect.runFork(
gateway
.request('prompt.submit', { session_id: sid, text })
.pipe(
Effect.catchCause(cause => Effect.sync(() => getLog().warn('submit', 'failed', { cause: String(cause) })))
)
)
}
// Slash dispatch context (Solid logic; the boundary just hands it a
// Promise-returning `request` + the host capabilities it needs).
const slashCtx: SlashContext = {
clearTranscript: () => store.clearTranscript(),
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
copyResponse: n => {
const text = nthAssistantResponse(store.state.messages, n)
if (!text) return false
void writeClipboard(text)
flashHint(n > 1 ? `Copied response #${n} to clipboard` : 'Copied response to clipboard')
return true
},
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
logTail: () =>
getLog()
.tail(200)
.map(e => `${e.scope}: ${e.msg}`),
openDashboard: () => store.openDashboard(),
openPager: (title, text) => store.openPager(title, text),
openPicker: picker => store.openPicker(picker),
openSwitcher: sessions => store.openSwitcher(sessions),
pushSystem: text => store.pushSystem(text),
quit: () => {
if (!renderer.isDestroyed) renderer.destroy()
},
request: (method, params) => Effect.runPromise(gateway.request(method, params)),
sessionId: () => gateway.sessionId(),
submit: submitPrompt
}
// Resume a chosen session (session switcher pick) — same hydrate path as launch.
const onResume = (resumeSid: string) => {
Effect.runFork(
resumeInto(gateway, store, resumeSid, input.cols).pipe(
Effect.catchCause(cause => Effect.sync(() => getLog().warn('resume', 'failed', { cause: String(cause) })))
)
)
}
// The composer's submit: route `/command` through the slash ladder, else a prompt.
const submit = (text: string) => {
if (text.startsWith('/')) void dispatchSlash(text, slashCtx)
else submitPrompt(text)
}
// Live completions (items 5 + 13): a `/command [args]` line queries
// `complete.slash` (the gateway completes names AND args); a trailing
// path-like word queries `complete.path` (file/@-mention tagging). The
// accepted item replaces from the gateway's `replace_from` (or the token
// start), so only the relevant token is spliced — not the whole line.
// Fired per keystroke (a debounce is a polish item).
const onType = (text: string) => {
const plan = planCompletion(text)
if (!plan) {
store.clearCompletions()
return
}
Effect.runPromise(gateway.request(plan.method, plan.params))
.then(result => store.setCompletions(mapCompletions(result), readReplaceFrom(result, plan.from)))
.catch(() => store.clearCompletions())
}
// Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same
// detached-runFork pattern; failures logged, never thrown into the view.
const respond = (method: string, params: Record<string, unknown>) => {
Effect.runFork(
gateway
.request(method, params)
.pipe(
Effect.catchCause(cause =>
Effect.sync(() => getLog().warn('respond', 'failed', { cause: String(cause), method }))
)
)
)
}
// 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.
// The theme is sourced reactively from the store (skin events update it).
yield* Effect.promise(() =>
render(
() => (
<KeymapProvider keymap={keymap}>
<ThemeProvider theme={() => store.state.theme}>
<App
store={store}
onSubmit={submit}
onType={onType}
onRespond={respond}
onResume={onResume}
sessionId={() => gateway.sessionId()}
history={history}
onImagePaste={onImagePaste}
pasteStore={pasteStore}
/>
</ThemeProvider>
</KeymapProvider>
),
renderer
)
)
// Block until the renderer is destroyed (Ctrl+C / quit); finalizers then run.
yield* Deferred.await(shutdown)
})
)
})
/** 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' })
for (const chunk of ['Hi ', 'there, ', 'glitch!']) {
controller.emit({ type: 'message.delta', payload: { text: chunk } })
}
controller.emit({ type: 'message.complete' })
}
if (import.meta.main) {
const fake = envFlag(process.env.HERMES_TUI_FAKE, false)
const cols = process.stdout.columns || 80
const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim()
const resumeId = process.env.HERMES_TUI_RESUME?.trim()
// Mouse on by default (opencode parity: wheel-scroll the transcript, drag the
// scrollbar, click-to-expand tools, text-aware selection). HERMES_TUI_MOUSE=0 opts out.
const mouse = envFlag(process.env.HERMES_TUI_MOUSE, true)
const base = { mouse, fake, cols }
const withPrompt = initialPrompt ? { ...base, initialPrompt } : base
const input: TuiInput = resumeId ? { ...withPrompt, resumeId } : withPrompt
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)
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Assistant-text extraction (the `/copy [n]` command's pure logic). An assistant
* turn's answer lives in `parts` (the `type:'text'` fragments, concatenated) while
* live, OR in `.text` once settled/resumed. We copy the ANSWER only — reasoning and
* tool parts are excluded. `nthAssistantResponse` indexes newest-first (1-based).
*
* NB: mouse-selection copies the RENDERED text verbatim (native OpenTUI selection,
* `selection.getSelectedText()`), not markdown source — markers are concealed in the
* pretty render and can't be recovered from a partial selection (user's choice). The
* source-bearing path is this `/copy` command, which copies a whole response's source.
*/
import type { Message } from './store.ts'
/** The answer text of one message: concat the `text` parts (trimmed) when live, else `.text`. */
export function messageText(m: Message): string {
if (m.parts && m.parts.length) {
return m.parts
.filter(p => p.type === 'text')
.map(p => p.text)
.join('')
.trim()
}
return m.text
}
/** Newest-first list of the non-empty answer text for every assistant message. */
export function assistantResponses(messages: Message[]): string[] {
const out: string[] = []
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (!m || m.role !== 'assistant') continue
const text = messageText(m)
if (text) out.push(text)
}
return out
}
/** The n-th newest assistant response (1-based; n=1 → last). `undefined` if out of range. */
export function nthAssistantResponse(messages: Message[], n: number): string | undefined {
return assistantResponses(messages)[n - 1]
}
+12
View File
@@ -0,0 +1,12 @@
/**
* deferClose — defer an overlay/prompt close by one tick.
*
* Overlays REPLACE the composer (a `<Switch>`), so when one closes the composer
* remounts + refocuses. Running the close on the NEXT tick lets the current
* key/close event (Esc/q/Enter/y/select) finish dispatching first, so the
* keystroke that triggered the close can't leak into the freshly-focused
* composer (e.g. `/clear`→y once left a stray "y" in the input).
*/
export function deferClose(fn: () => void): void {
setTimeout(fn, 0)
}
+16
View File
@@ -0,0 +1,16 @@
/**
* env — shared boolean env-flag parsing (one source for the TRUE/FALSE regexes).
*
* Recognized truthy values: 1/true/yes/on; falsy: 0/false/no/off (case-insensitive,
* surrounding whitespace trimmed). Anything else (incl. unset) is "unrecognized".
*/
export const TRUE_RE = /^(?:1|true|yes|on)$/i
export const FALSE_RE = /^(?:0|false|no|off)$/i
/** Parse a boolean env var; returns `fallback` when unset/unrecognized. */
export function envFlag(value: string | undefined, fallback: boolean): boolean {
const v = value?.trim() ?? ''
if (TRUE_RE.test(v)) return true
if (FALSE_RE.test(v)) return false
return fallback
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Pure recovery-budget policy for the gateway exit handler (LOGIC side — no
* Effect, no refs, no UI). Ported from Ink's `ui-tui/src/app/gatewayRecovery.ts`
* and EXTENDED with opencode-style exponential backoff.
*
* A gateway that crash-loops on startup must not let the TUI spawn-storm, so
* respawn+resume attempts are capped to GATEWAY_RECOVERY_LIMIT within a sliding
* GATEWAY_RECOVERY_WINDOW_MS; past the budget the app falls back to the inert
* "gateway exited" state. Kept pure (no refs/UI) so the bound — including the
* crash-loop case — is unit-testable.
*/
export const GATEWAY_RECOVERY_LIMIT = 3
export const GATEWAY_RECOVERY_WINDOW_MS = 60_000
export interface RecoveryPlan {
/** Attempt timestamps to persist (the pruned window, plus `now` iff recovering). */
attempts: number[]
recover: boolean
/**
* Session to resume — the live sid, or the not-yet-consumed recovery target
* when the live sid was already cleared by a prior exit.
*/
sid: null | string
}
/**
* Decide whether to respawn+resume after a gateway death. `liveSid` is the
* current session (nulled on the first exit); `recoverSid` is a pending
* recovery target carried across a respawn that died before gateway.ready —
* so a startup crash-loop keeps retrying the same session up to the budget
* instead of stranding it after one attempt.
*/
export function planGatewayRecovery(
liveSid: null | string,
recoverSid: null | string,
attempts: number[],
now: number
): RecoveryPlan {
const sid = liveSid ?? recoverSid
const recent = attempts.filter(t => now - t < GATEWAY_RECOVERY_WINDOW_MS)
const recover = Boolean(sid) && recent.length < GATEWAY_RECOVERY_LIMIT
return { attempts: recover ? [...recent, now] : recent, recover, sid }
}
/**
* Exponential backoff between respawn attempts (opencode-style): 1s, 2s, 4s, …
* capped at 30s. `attempt` is 1-based (the first respawn waits 1s).
*/
export function backoffMs(attempt: number): number {
return Math.min(1000 * 2 ** Math.max(0, attempt - 1), 30_000)
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Prompt history (item 6) — the SOLID side, plain TS. Up/Down cycle through the
* prompts you've sent, scoped PER DIRECTORY: launching Hermes again in the same
* project dir reuses that dir's prior prompts (the "bleed for the same dir" the
* user asked for), while a session in a different dir keeps its own list.
*
* `createPromptHistory` is pure + injectable (initial entries + a `persist`
* sink) so the cursor logic is unit-tested with no filesystem. The real wiring
* uses `loadDirHistory(cwd)` / `dirHistoryPersister(cwd)` to read/append a
* per-dir JSONL file under `$HERMES_HOME/tui-history/<hash>.jsonl` (one
* JSON-encoded prompt per line, multiline-safe; opencode's prompt-history.jsonl
* model, Ink's ~/.hermes/.hermes_history idea, scoped by dir).
*/
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { createHash } from 'node:crypto'
import { dirname, join } from 'node:path'
const DEFAULT_MAX = 200
export interface PromptHistoryOptions {
/** Entries already on disk for this dir (oldest → newest). */
initial?: string[]
/** Persist a newly pushed prompt (real use: append to the per-dir file). */
persist?: (text: string) => void
/** Cap on retained entries (oldest dropped). */
max?: number
}
export interface PromptHistory {
/** All cycleable entries (oldest → newest) — loaded prev-session + this session. */
entries: () => string[]
/** Record a submitted prompt (skips a consecutive duplicate) and reset the cursor. */
push: (text: string) => void
/** Cycle to the OLDER entry (Up). Stashes `currentInput` as the draft on the first step. */
prev: (currentInput: string) => string | null
/** Cycle to the NEWER entry (Down); returns the stashed draft at the bottom. */
next: () => string | null
/** Reset the cursor to the live draft (call on any edit). */
reset: () => void
}
export function createPromptHistory(opts: PromptHistoryOptions = {}): PromptHistory {
const entries = [...(opts.initial ?? [])]
const max = opts.max ?? DEFAULT_MAX
// `idx === entries.length` means "at the live draft" (past the newest entry).
let idx = entries.length
let draft = ''
return {
entries: () => entries.slice(),
push(text) {
if (!text.trim()) return
if (entries[entries.length - 1] !== text) {
entries.push(text)
if (entries.length > max) entries.shift()
opts.persist?.(text)
}
idx = entries.length
draft = ''
},
prev(currentInput) {
if (entries.length === 0) return null
if (idx === entries.length) draft = currentInput // leaving the bottom — stash the draft
if (idx > 0) idx--
return entries[idx] ?? null
},
next() {
if (idx >= entries.length) return null
idx++
return idx === entries.length ? draft : (entries[idx] ?? null)
},
reset() {
idx = entries.length
}
}
}
// ── per-directory file persistence (best-effort; never throws) ──────────
function hermesHome(): string {
return process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
}
/** The history file for a given working directory (keyed by a hash of the abs path). */
function dirHistoryPath(cwd: string): string {
const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16)
return join(hermesHome(), 'tui-history', `${key}.jsonl`)
}
/** Load a directory's prior prompts (oldest → newest); [] if none / unreadable. */
export function loadDirHistory(cwd: string, max = DEFAULT_MAX): string[] {
try {
const raw = readFileSync(dirHistoryPath(cwd), 'utf8')
const out: string[] = []
for (const line of raw.split('\n')) {
if (!line.trim()) continue
try {
const v: unknown = JSON.parse(line)
if (typeof v === 'string') out.push(v)
} catch {
// skip a corrupt line — never let it break loading
}
}
return out.length > max ? out.slice(out.length - max) : out
} catch {
return []
}
}
/** A persister that appends each pushed prompt to the dir's JSONL file (best-effort). */
export function dirHistoryPersister(cwd: string): (text: string) => void {
const path = dirHistoryPath(cwd)
return text => {
try {
mkdirSync(dirname(path), { recursive: true })
appendFileSync(path, JSON.stringify(text) + '\n', 'utf8')
} catch {
// history persistence is non-essential — a write failure must not disrupt the turn
}
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Pasted-text placeholders (free-code's model). A large paste isn't dumped raw
* into the composer — instead a compact `[Pasted text #N +M lines]` chip is shown
* and the real content is held in a Map, then expanded back on submit. Pure + no
* OpenTUI imports → trivially unit-testable.
*
* The store is created ONCE per session (entry) and passed to the Composer, so it
* survives the composer remounting when overlays open/close (a per-composer store
* would lose a pending paste mid-compose).
*/
export interface PasteStore {
/** Register a pasted block; returns the placeholder to insert into the input. */
add(text: string): string
/** Replace every `[Pasted text #N …]` placeholder with its stored content. */
expand(input: string): string
/** Drop all stored pastes (call after a successful submit). */
clear(): void
}
// Matches `[Pasted text #12]` and `[Pasted text #12 +34 lines]`. The id is the key.
const REF = /\[Pasted text #(\d+)(?: \+\d+ lines)?\]/g
export function createPasteStore(): PasteStore {
const map = new Map<number, string>()
let seq = 0
return {
add(text) {
const id = ++seq
map.set(id, text)
const lines = text.split('\n').length
return lines > 1 ? `[Pasted text #${id} +${lines} lines]` : `[Pasted text #${id}]`
},
// String.replace(/g) is a SINGLE left-to-right pass over the ORIGINAL string,
// so content inserted for one ref is never re-scanned for another ref —
// a pasted block that itself contains `[Pasted text #k]` is safe.
expand(input) {
return (input ?? '').replace(REF, (m, id: string) => map.get(Number(id)) ?? m)
},
clear() {
map.clear()
seq = 0
}
}
}
/** A paste big enough to placeholder rather than inline (conservative thresholds). */
export function shouldPlaceholder(text: string): boolean {
return text.split('\n').length >= 4 || text.length > 400
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Resume snapshot mapper (spec §1 lifecycle; gotcha §8 #5). Maps the
* `session.resume` response `messages` (tui_gateway `_history_to_messages`) into
* the store's `Message[]`. Each history entry is either `{role, text}` (user/
* assistant/system) or `{role:'tool', name, context}` (NO text — render it).
*
* Tool rows are folded into the PRECEDING assistant turn's ordered `parts[]`
* (state:'complete', summary=context) so a resumed transcript renders inline like
* a live one. Resumed assistant text is given a single text part so it renders
* through the native markdown path. IDs are `r*` (distinct from live `p*`).
*/
import type { Message, Part, SessionItem, ToolPartState } from './store.ts'
import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
function readStr(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const v = (value as { [k: string]: unknown })[key]
return typeof v === 'string' ? v : undefined
}
function readNum(value: unknown, key: string): number {
if (!value || typeof value !== 'object') return 0
const v = (value as { [k: string]: unknown })[key]
return typeof v === 'number' ? v : 0
}
/** Map a `session.list` result into switcher rows (loose-typed read). */
export function mapSessionList(result: unknown): SessionItem[] {
if (!result || typeof result !== 'object') return []
const sessions = (result as { sessions?: unknown }).sessions
if (!Array.isArray(sessions)) return []
const out: SessionItem[] = []
for (const s of sessions) {
const id = readStr(s, 'id')
if (!id) continue
out.push({
id,
messageCount: readNum(s, 'message_count'),
preview: readStr(s, 'preview') ?? '',
title: readStr(s, 'title') ?? ''
})
}
return out
}
export function mapResumeHistory(history: unknown): Message[] {
if (!Array.isArray(history)) return []
const out: Message[] = []
let seq = 0
const id = () => `r${++seq}`
let currentAssistant: Message | undefined
for (const raw of history) {
const role = readStr(raw, 'role')
if (role === 'tool') {
const name = readStr(raw, 'name') ?? 'tool'
const context = readStr(raw, 'context')
const tool: ToolPartState = { type: 'tool', id: id(), name, state: 'complete' }
// Match the live tool part exactly (item 1): primary-arg preview in the
// header, plus the (capped) output so resumed tools are collapsible too.
if (context) tool.argsPreview = context
const rawResult = readStr(raw, 'result_text')
if (rawResult) {
const { body, omittedNote } = stripOmittedNote(rawResult)
const resultText = stripToolEnvelope(body)
if (resultText) {
tool.resultText = resultText
tool.lineCount = resultText.replace(/\s+$/, '').split('\n').length
}
if (omittedNote) tool.omittedNote = omittedNote
}
const args = (raw as { args?: unknown }).args
if (args && typeof args === 'object') {
try {
tool.argsText = JSON.stringify(args, null, 2)
} catch {
/* unstringifiable — leave unset */
}
}
if (!currentAssistant) {
currentAssistant = { role: 'assistant', text: '', parts: [] }
out.push(currentAssistant)
}
;(currentAssistant.parts ??= []).push(tool)
continue
}
const text = readStr(raw, 'text') ?? ''
if (role === 'assistant') {
const parts: Part[] = text ? [{ type: 'text', id: id(), text }] : []
currentAssistant = { role: 'assistant', text, parts }
out.push(currentAssistant)
} else if (role === 'user' || role === 'system') {
out.push({ role, text })
currentAssistant = undefined
}
}
return out
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Slash command system — the SOLID side (spec §1; mirrors Ink
* `app/createSlashHandler.ts` + `domain/slash.ts`). Plain functions/data, NOT
* Effect; the boundary injects a Promise-returning `request` so dispatch can call
* `slash.exec` / `command.dispatch` / `commands.catalog`.
*
* Dispatch ladder (Ink parity):
* 1. client-local command (the TUI-only set — handled in-process)
* 2. `slash.exec {command, session_id}` → `{output, warning?}` → system line
* 3. on reject → `command.dispatch {arg, name, session_id}` → typed action
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
* prefill → notice). Long output routes to the pager (Phase 5a).
*/
import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts'
export interface ParsedSlash {
name: string
arg: string
}
/** Parse `/name rest…` → {name, arg}; null if not a slash command. */
export function parseSlash(input: string): ParsedSlash | null {
if (!input.startsWith('/')) return null
const body = input.slice(1).trimStart()
if (!body) return null
const sp = body.indexOf(' ')
return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) }
}
/** The host capabilities the dispatcher needs (wired by the entry boundary). */
export interface SlashContext {
/** Server RPC (resolves with the result, rejects on GatewayError). */
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
readonly sessionId: () => string | undefined
readonly pushSystem: (text: string) => void
/** Open the full-screen pager (long output: /status, /logs, …). */
readonly openPager: (title: string, text: string) => void
/** Submit a user turn (skill/send dispatch results). */
readonly submit: (text: string) => void
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
readonly confirm: (message: string, onConfirm: () => void) => void
readonly clearTranscript: () => void
/** Copy the n-th newest assistant response to the clipboard; returns whether something was copied. */
readonly copyResponse: (n: number) => boolean
readonly quit: () => void
/** Recent log lines for `/logs` (the ring buffer). */
readonly logTail: () => string[]
/** Fetch the resumable sessions (`session.list`) for the switcher. */
readonly listSessions: () => Promise<SessionItem[]>
/** Open the session switcher with the given rows. */
readonly openSwitcher: (sessions: SessionItem[]) => void
/** Open a generic picker (model picker, skills hub). */
readonly openPicker: (picker: PickerState) => void
/** Open the agents dashboard (/agents, /tasks). */
readonly openDashboard: () => void
}
function readStr(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const v = (value as { [k: string]: unknown })[key]
return typeof v === 'string' ? v : undefined
}
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
/** A planned completion query (item 5/13): which RPC + params, and where an
* accepted item replaces from if the RPC omits its own `replace_from`. */
export interface CompletionPlan {
method: 'complete.slash' | 'complete.path'
params: Record<string, unknown>
from: number
}
/** A path-like last token worth file/@-mention completion (mirrors Ink's TAB_PATH_RE intent). */
function isPathLike(word: string): boolean {
return (
word.startsWith('@') ||
word.startsWith('~') ||
word.startsWith('./') ||
word.startsWith('../') ||
word.startsWith('/') ||
word.includes('/')
)
}
/**
* Decide what to complete for the current composer text (cursor assumed at end):
* - `/command [args]` → `complete.slash {text}` (the gateway completes names AND
* args, e.g. /details section names),
* - a trailing path-like word (`@…`, `~/…`, `./…`, `/…`, or anything with `/`) →
* `complete.path {word}` for file/dir tagging,
* - otherwise nothing.
* Returns null when there's no completion to run (so the dropdown clears).
*/
export function planCompletion(text: string): CompletionPlan | null {
if (text.includes('\n')) return null
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } }
const word = /(\S+)$/.exec(text)?.[1]
if (word && isPathLike(word)) {
return { from: text.length - word.length, method: 'complete.path', params: { word } }
}
return null
}
/** Read a `replace_from` offset off a completion result, falling back to `fallback`. */
export function readReplaceFrom(result: unknown, fallback: number): number {
if (result && typeof result === 'object') {
const rf = (result as { replace_from?: unknown }).replace_from
if (typeof rf === 'number') return rf
}
return fallback
}
/** Map a `complete.slash`/`complete.path` result ({items:[{text,display,meta}]}) into candidates. */
export function mapCompletions(result: unknown): CompletionItem[] {
if (!result || typeof result !== 'object') return []
const items = (result as { items?: unknown }).items
if (!Array.isArray(items)) return []
const out: CompletionItem[] = []
for (const it of items) {
const text = readStr(it, 'text')
if (!text) continue
out.push({ display: readStr(it, 'display') ?? text, meta: readStr(it, 'meta') ?? '', text })
}
return out
}
/** Long output → the pager; short → a system line (Ink: >180 chars or >2 lines). */
function present(ctx: SlashContext, title: string, text: string): void {
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
if (long) ctx.openPager(title, text)
else ctx.pushSystem(text)
}
const CLIENT_HELP = [
'/help — list commands',
'/model [name] — switch model (picker if bare)',
'/copy [n] — copy the last (or n-th) response',
'/skills — browse skills',
'/sessions, /resume — switch/resume a session',
'/clear, /new — clear the transcript (confirm)',
'/logs — recent engine log lines',
'/quit, /exit — quit',
'(other /commands run on the gateway)'
].join('\n')
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
/** Fetch sessions and open the switcher (shared by /sessions, /resume, /switch, /session). */
const openSwitcher: ClientHandler = async (_arg, ctx) => {
const sessions = await ctx.listSessions()
if (sessions.length) ctx.openSwitcher(sessions)
else ctx.pushSystem('No sessions to resume.')
}
/** Flatten `model.options` (authenticated providers' models) into picker rows; mark the current. */
function mapModelOptions(opts: unknown): PickerItem[] {
if (!opts || typeof opts !== 'object') return []
const providers = (opts as { providers?: unknown }).providers
if (!Array.isArray(providers)) return []
const current = readStr(opts, 'model')
const items: PickerItem[] = []
for (const p of providers) {
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
const models = (p as { models?: unknown }).models
if (!Array.isArray(models)) continue
for (const m of models) {
if (typeof m === 'string') items.push({ description: slug, label: m === current ? `${m}` : m, value: m })
}
}
return items
}
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into picker rows. */
function mapSkills(result: unknown): PickerItem[] {
if (!result || typeof result !== 'object') return []
const skills = (result as { skills?: unknown }).skills
if (!skills || typeof skills !== 'object') return []
const items: PickerItem[] = []
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
if (!Array.isArray(names)) continue
for (const n of names) if (typeof n === 'string') items.push({ description: category, label: n, value: n })
}
return items
}
/** Switch the model via the server (shared by `/model <name>` and the picker pick). */
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
try {
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
ctx.pushSystem(readStr(r, 'output') || `${name}`)
} catch (error) {
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
}
}
/** `/model` — bare opens the model picker; `/model <name>` switches directly. */
const modelCmd: ClientHandler = async (arg, ctx) => {
if (arg.trim()) {
await switchModel(ctx, arg.trim())
return
}
const items = mapModelOptions(await ctx.request('model.options', {}))
if (!items.length) {
ctx.pushSystem('No models available (no authenticated providers).')
return
}
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
}
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
const skillsCmd: ClientHandler = async (_arg, ctx) => {
const items = mapSkills(await ctx.request('skills.manage', { action: 'list' }))
if (!items.length) {
ctx.pushSystem('No skills found.')
return
}
ctx.openPicker({
items,
onPick: name =>
void ctx
.request('skills.manage', { action: 'inspect', query: name })
.then(info => ctx.openPager(`Skill: ${name}`, readStr(info, 'info') || JSON.stringify(info, null, 2)))
.catch(() => ctx.pushSystem(`/skills: could not inspect ${name}`)),
title: 'Skills'
})
}
/** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
const toolsCmd: ClientHandler = async (arg, ctx) => {
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
try {
const r = await ctx.request('slash.exec', { command, session_id: ctx.sessionId() })
ctx.openPager('Tools', readStr(r, 'output') || '(no tool info)')
} catch (error) {
ctx.pushSystem(`/tools: ${error instanceof Error ? error.message : 'failed'}`)
}
}
/** The TUI-only client commands (run in-process, never hit the gateway). */
const CLIENT: Record<string, ClientHandler> = {
agents: (_arg, ctx) => ctx.openDashboard(),
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
copy: (arg, ctx) => {
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
},
exit: (_arg, ctx) => ctx.quit(),
model: modelCmd,
resume: openSwitcher,
session: openSwitcher,
sessions: openSwitcher,
skills: skillsCmd,
switch: openSwitcher,
tasks: (_arg, ctx) => ctx.openDashboard(),
tools: toolsCmd,
help: async (_arg, ctx) => {
// Prefer the live catalog; fall back to the client list if it's unavailable.
try {
const cat = await ctx.request('commands.catalog', {})
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
} catch {
ctx.pushSystem(CLIENT_HELP)
}
},
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
quit: (_arg, ctx) => ctx.quit()
}
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
* (tui_gateway/server.py `commands.catalog`). */
function renderCatalog(cat: unknown): string {
if (!cat || typeof cat !== 'object') return ''
const pairs = (cat as { pairs?: unknown }).pairs
if (!Array.isArray(pairs)) return ''
const lines = pairs
.map(pair => {
if (!Array.isArray(pair) || typeof pair[0] !== 'string') return null
const desc = typeof pair[1] === 'string' ? pair[1] : ''
return desc ? `${pair[0]}${desc}` : pair[0]
})
.filter((l): l is string => l !== null)
return lines.length ? lines.join('\n') : ''
}
function handleDispatchResult(parsed: ParsedSlash, raw: unknown, ctx: SlashContext): void {
const type = readStr(raw, 'type')
const argTail = parsed.arg ? ` ${parsed.arg}` : ''
switch (type) {
case 'exec':
case 'plugin':
ctx.pushSystem(readStr(raw, 'output') || '(no output)')
return
case 'alias': {
const target = readStr(raw, 'target')
if (target) void dispatchSlash(`/${target}${argTail}`, ctx)
return
}
case 'skill':
case 'send': {
const notice = readStr(raw, 'notice')
if (notice) ctx.pushSystem(notice)
const message = readStr(raw, 'message')
if (message?.trim()) ctx.submit(message)
else ctx.pushSystem(`/${parsed.name}: empty message`)
return
}
case 'prefill': {
// /undo etc. — composer prefill lands with the composer-ref plumbing; show it for now.
const message = readStr(raw, 'message')
ctx.pushSystem(message ? `(edit & resubmit) ${message}` : `/${parsed.name}: nothing to prefill`)
return
}
default:
ctx.pushSystem(`error: invalid response: command.dispatch`)
}
}
/** Dispatch a `/command` through the ladder. Returns once the (async) work settles. */
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<void> {
const parsed = parseSlash(input)
if (!parsed) return
const client = CLIENT[parsed.name]
if (client) {
await client(parsed.arg, ctx)
return
}
const sid = ctx.sessionId()
try {
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
const output = readStr(result, 'output') || `/${parsed.name}: no output`
const warning = readStr(result, 'warning')
const text = warning ? `warning: ${warning}\n${output}` : output
// Long output → pager (Ink: >180 chars or >2 non-empty lines), else a system line.
present(ctx, titleCase(parsed.name), text)
} catch {
try {
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
handleDispatchResult(parsed, raw, ctx)
} catch (error) {
ctx.pushSystem(`error: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
+863
View File
@@ -0,0 +1,863 @@
/**
* Session/message store — the SOLID side (spec v4 §1, §7). 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 2b: an assistant turn is ONE ordered `parts[]` of a discriminated union
* (text / reasoning / tool), so tool calls render INLINE between text blocks
* instead of dumped as separate rows below (§7 — the "dump-below" bug). Tools are
* matched start↔complete by `tool_id`; `tool.complete` updates that part IN PLACE.
* User/system rows stay flat `text` (no parts). Carried from Phase 1: streaming
* concat (prefer `payload.text`), skin→theme, LRU dedup, hydrate-while-buffering.
*/
import { Option } from 'effect'
import { createStore, produce } from 'solid-js/store'
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
import {
decodeCatalog,
decodeSessionInfoPatch,
type CatalogDecoded,
type SessionInfoPatchDecoded
} from '../boundary/schema/SessionInfo.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
/** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */
export interface ToolPartState {
type: 'tool'
id: string
name: string
state: 'running' | 'complete'
/** Envelope-stripped output (multi-line → block render; the view caps it). */
resultText?: string
/** Short one-line status when there's no substantial output. */
summary?: string
error?: string
lineCount?: number
/** One-line primary-arg preview from gateway `context` (always sent; redaction-safe). */
argsPreview?: string
/** Full args (pretty JSON) for the expanded view — `args_text` (redacted) or stringified `args`. */
argsText?: string
/** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */
duration?: number
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
omittedNote?: string
}
/** One ordered piece of an assistant turn (§7). */
export type Part =
| { type: 'text'; id: string; text: string }
| { type: 'reasoning'; id: string; text: string }
| ToolPartState
export interface Message {
readonly role: 'user' | 'assistant' | 'system'
/** Flat body for user/system rows (and settled/resumed assistant rows). */
text: string
/** Ordered parts for a live assistant turn; absent for user/system. */
parts?: Part[]
streaming?: boolean
}
/**
* A BLOCKING interactive request from the agent (spec §8 #6 — unhandled = deadlock).
* Each is answered via the matching `*.respond` RPC; Esc/Ctrl+C sends deny/empty.
*/
export type ActivePrompt =
| { kind: 'clarify'; question: string; choices: string[] | null; requestId: string }
| { kind: 'approval'; command: string; description: string }
| { kind: 'sudo'; requestId: string }
| { kind: 'secret'; envVar: string; prompt: string; requestId: string }
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
| { kind: 'confirm'; message: string; onConfirm: () => void }
/** A full-screen scrollable text viewer (long slash output: /status, /logs, …). */
export interface PagerState {
title: string
text: string
}
/** One row in the session switcher (from `session.list`). */
export interface SessionItem {
id: string
title: string
preview: string
messageCount: number
}
/** A row in a generic `<select>` picker (model picker, skills hub, …). */
export interface PickerItem {
label: string
description?: string
value: string
}
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */
export interface PickerState {
title: string
items: PickerItem[]
onPick: (value: string) => void
}
/** A slash-completion candidate (from `complete.slash`). */
export interface CompletionItem {
text: string
display: string
meta: string
}
/** A delegated subagent, tracked from the `subagent.*` event stream (agents dashboard). */
export interface SubagentInfo {
id: string
goal: string
status: string
depth: number
model?: string
parentId?: string
summary?: string
lastTool?: string
/** Live activity trace (item 15) — tool/progress/summary lines, newest last. */
trace?: string[]
/** Latest thinking text (transient; not appended to the trace to avoid flooding). */
thought?: string
}
/** Cap on a subagent's retained trace lines. */
const SUBAGENT_TRACE_LIMIT = 200
/**
* Live session chrome (the status bar — item 14). Sourced from the `session.info`
* event (and the `session.create`/`resume` result's `info`), refreshed whenever
* the gateway's agent/config state changes. `running` is the turn-active flag the
* Ctrl-C interrupt (item 11) reads; we also flip it locally on message.start/
* complete so the bar reacts instantly even if a `session.info` lags.
*/
export interface SessionInfo {
model?: string
effort?: string
fast?: boolean
cwd?: string
branch?: string
running?: boolean
contextUsed?: number
contextMax?: number
contextPercent?: number
compressions?: number
}
/** Startup catalog (tools/skills/MCP) for the home-screen panel (item 9 / banner parity). */
export interface Catalog {
readonly tools: {
readonly total: number
readonly toolsets: ReadonlyArray<{ name: string; count: number; enabled: boolean; tools: ReadonlyArray<string> }>
}
readonly skills: { readonly total: number; readonly categories: ReadonlyArray<{ name: string; count: number }> }
readonly mcp: { readonly servers: ReadonlyArray<string> }
}
export interface StoreState {
ready: boolean
messages: Message[]
/** Count of oldest messages trimmed from the DISPLAY by the rolling cap (live
* overflow + resume slice). Drives the "N earlier messages" truncation notice;
* 0 when nothing's been dropped. NOT context loss — the model's history lives on
* the gateway (see MESSAGE_CAP); this only bounds in-TUI scrollback. */
dropped: number
theme: Theme
/** The active blocking prompt (composer is hidden while set); undefined when none. */
prompt: ActivePrompt | undefined
/** The open pager overlay (replaces the transcript while set); undefined when none. */
pager: PagerState | undefined
/** The open session switcher (replaces the composer while set); undefined when none. */
switcher: SessionItem[] | undefined
/** The open generic picker (model/skills/…); undefined when none. */
picker: PickerState | undefined
/** Live completion candidates (slash-name/args or file/@-mention) shown above the composer. */
completions: CompletionItem[] | undefined
/** Char offset in the input where an accepted completion should start replacing
* (gateway `replace_from` for slash args; the path-token start for @-mentions). */
completionFrom: number
/** Delegated subagents (from `subagent.*`), shown in the agents dashboard. */
subagents: SubagentInfo[]
/** Whether the agents dashboard overlay is open (/agents). */
dashboard: boolean
/** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`);
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
status: string | undefined
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
info: SessionInfo
/** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11);
* takes visual priority over the busy `status` face. Undefined when none. */
hint: string | undefined
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
catalog: Catalog | undefined
/** The current session id (shown in the home panel; updated on create/resume). */
sessionId: string | undefined
}
const LRU_LIMIT = 1000
/** Read a string field off an unknown payload record (no `any`, no cast). */
function readStr(payload: { readonly [k: string]: unknown }, key: string): string | undefined {
const v = payload[key]
return typeof v === 'string' ? v : undefined
}
/** Read a number field off an unknown payload record. */
function readNum(payload: { readonly [k: string]: unknown }, key: string): number {
const v = payload[key]
return typeof v === 'number' ? v : 0
}
/** Read an optional number (undefined when absent) — distinguishes "0" from "missing". */
function readOptNum(payload: { readonly [k: string]: unknown }, key: string): number | undefined {
const v = payload[key]
return typeof v === 'number' ? v : undefined
}
/**
* Fold a `session.info` / `session.create.info` payload into a partial SessionInfo.
* The loose wire JSON is decoded ONCE via `SessionInfoPatchSchema` (decode-at-
* boundary); context/usage numbers are read from the nested `usage` object first,
* falling back to the top level (the gateway shapes vary by RPC vs event). A
* malformed payload decodes to `Option.none` → an empty patch (never crashes).
* Only present fields are included so a partial patch can't clobber prior chrome.
*/
function readInfoPatch(payload: { readonly [k: string]: unknown }): Partial<SessionInfo> {
const decoded = decodeSessionInfoPatch(payload)
if (Option.isNone(decoded)) return {}
return infoPatchFrom(decoded.value)
}
/** Build the SessionInfo patch from a decoded session.info payload. */
function infoPatchFrom(d: SessionInfoPatchDecoded): Partial<SessionInfo> {
const patch: Partial<SessionInfo> = {}
if (d.model) patch.model = d.model
if (d.reasoning_effort) patch.effort = d.reasoning_effort
if (d.fast !== undefined) patch.fast = d.fast
if (d.cwd) patch.cwd = d.cwd
if (d.branch) patch.branch = d.branch
if (d.running !== undefined) patch.running = d.running
// prefer the nested usage.context_* numbers, else the top-level fallback.
const used = d.usage?.context_used ?? d.context_used
if (used !== undefined) patch.contextUsed = used
const max = d.usage?.context_max ?? d.context_max
if (max !== undefined) patch.contextMax = max
const pct = d.usage?.context_percent ?? d.context_percent
if (pct !== undefined) patch.contextPercent = pct
const comp = d.usage?.compressions ?? d.compressions
if (comp !== undefined) patch.compressions = comp
return patch
}
/** Keep only the string elements of a decoded (unknown-element) array. */
function onlyStrings(items: ReadonlyArray<unknown> | undefined): string[] {
return (items ?? []).filter((s): s is string => typeof s === 'string')
}
/** Build the typed Catalog from a decoded startup.catalog result (item 9). An
* absent `enabled` flag means on; nameless toolsets/categories are dropped and
* non-string tool/server names are filtered (defensive — wire arrays are loose). */
function catalogFrom(d: CatalogDecoded): Catalog {
return {
mcp: { servers: onlyStrings(d.mcp?.servers) },
skills: {
total: d.skills?.total ?? 0,
categories: (d.skills?.categories ?? [])
.map(c => ({ count: c.count ?? 0, name: c.name ?? '' }))
.filter(c => c.name)
},
tools: {
total: d.tools?.total ?? 0,
toolsets: (d.tools?.toolsets ?? [])
.map(t => ({
count: t.count ?? 0,
enabled: t.enabled !== false,
name: t.name ?? '',
tools: onlyStrings(t.tools)
}))
.filter(t => t.name)
}
}
}
/** The subagent status implied by an event type (an explicit payload `status` wins). */
function subagentStatusFor(type: string): string {
if (type === 'subagent.complete') return 'complete'
if (type === 'subagent.thinking') return 'thinking'
if (type === 'subagent.tool') return 'tool'
if (type === 'subagent.progress') return 'working'
return 'running'
}
export function createSessionStore() {
// Rolling cap on retained transcript rows. OpenTUI lays out via Yoga (WASM), whose
// linear memory is grow-only — every live `<For>` row is a Yoga-node subtree, so an
// uncapped `messages[]` ratchets the high-water mark up over a long session and never
// gives it back. Capping the array in place (see `capMessages`) makes Solid's keyed
// `<For>` UNMOUNT exactly the evicted oldest rows → `Renderable.destroy()` →
// `yogaNode.free()`, returning those nodes to the WASM allocator's free list.
//
// Default 3000 (≈1500 turns of scrollback): the highest cap whose steady-state RSS
// stays within a sane TUI budget on the realistic-fixture bench (~20.4 renderables/
// msg, ~0.65 MB/msg → ~2 GB at 3000 — and that ceiling is only reached by marathon
// 3000+-message sessions; typical sessions cost a fraction). opencode caps at 100;
// we trade memory for far more in-TUI scrollback (the dashboard holds the rest).
// Read once per store from `HERMES_TUI_MAX_MESSAGES`. Turns trimmed beyond the cap
// aren't lost — they live on the gateway and are recoverable via `/resume`.
const MESSAGE_CAP = (() => {
const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10)
return Number.isFinite(raw) && raw > 0 ? raw : 3000
})()
const [state, setState] = createStore<StoreState>({
ready: false,
messages: [],
dropped: 0,
theme: DEFAULT_THEME,
prompt: undefined,
pager: undefined,
switcher: undefined,
picker: undefined,
completions: undefined,
completionFrom: 0,
subagents: [],
dashboard: false,
status: undefined,
info: {},
hint: undefined,
catalog: undefined,
sessionId: undefined
})
// Monotonic part id (stable `key` per part so a new tool part below a streaming
// text part doesn't remount/re-tokenize it).
let partSeq = 0
const nextId = () => `p${++partSeq}`
// 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
// Anti-flood for `gateway.stderr`: a crashing child can emit a torrent of
// stderr lines, so we do NOT push each to the transcript. Instead we keep a
// small ring of the most-recent lines and only surface a TAIL of it when a
// failure event (start_timeout / exited) actually needs the diagnostic
// context — so a healthy-but-chatty gateway never spams the chat.
const STDERR_RING_LIMIT = 20
const STDERR_TAIL = 5
const stderrRing: string[] = []
function stderrTail(): string {
return stderrRing.slice(-STDERR_TAIL).join('\n')
}
function setSkin(skin: GatewaySkinDecoded | undefined): void {
setState('theme', themeFromSkin(skin))
}
// Trim the transcript to MESSAGE_CAP, dropping the OLDEST rows IN PLACE via
// `splice` (NOT a `slice`-reassign). A keyed `<For>` keeps rows by item
// REFERENCE, so splicing the head unmounts only the evicted rows (freeing their
// Yoga nodes) while the survivors keep their refs and are not remounted. A live
// streaming assistant turn is always the LAST row, so head-trimming never drops it.
function capMessages(draft: StoreState): void {
const overflow = draft.messages.length - MESSAGE_CAP
if (overflow > 0) {
draft.messages.splice(0, overflow)
draft.dropped += overflow
}
}
// ── parts helpers (operate on a draft message inside produce) ───────────
function appendPart(m: Message, type: 'text' | 'reasoning', text: string): void {
const parts = (m.parts ??= [])
const last = parts[parts.length - 1]
if (last && last.type === type) last.text += text
else parts.push({ type, id: nextId(), text })
}
/** The live (last) assistant message, optionally only when still streaming. */
function liveAssistant(draft: StoreState, streamingOnly = false): Message | undefined {
const last = draft.messages[draft.messages.length - 1]
if (last && last.role === 'assistant' && (!streamingOnly || last.streaming)) return last
return undefined
}
/** Ensure there's an open assistant turn to attach parts to (tool/reasoning). */
function ensureAssistant(draft: StoreState): Message {
const live = liveAssistant(draft, true)
if (live) return live
const created: Message = { role: 'assistant', text: '', parts: [], streaming: true }
draft.messages.push(created)
return created
}
/** Find a tool part by id in the CURRENT (last) assistant turn — a tool.complete
* always pairs with a tool.start in the live turn, so scoping there avoids
* matching a same-id tool in an older/resumed turn (and is O(parts), not O(all)). */
function findToolPart(draft: StoreState, id: string): ToolPartState | undefined {
const parts = liveAssistant(draft)?.parts
if (!parts) return undefined
for (let j = parts.length - 1; j >= 0; j--) {
const p = parts[j]
if (p && p.type === 'tool' && p.id === id) return p
}
return undefined
}
/** Push a user message (composer submit). */
function pushUser(text: string) {
setState(
produce(draft => {
draft.messages.push({ role: 'user', text })
capMessages(draft)
})
)
}
/** Push a system line (slash output, errors, notices). */
function pushSystem(text: string) {
// slash/notice text is often ANSI-colored for the Ink TUI; strip codes so
// they don't render as literal `[1;38m…` glyphs in the native engine (item 8).
const clean = stripAnsi(text)
setState(
produce(draft => {
draft.messages.push({ role: 'system', text: clean })
capMessages(draft)
})
)
}
/** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */
function clearTranscript() {
setState('messages', [])
setState('subagents', [])
setState('dropped', 0)
// Drop the dedup history too — a fresh transcript should re-process any id.
applied.clear()
}
/** Open / close the agents dashboard overlay (/agents). */
function openDashboard() {
setState('dashboard', true)
}
function closeDashboard() {
setState('dashboard', false)
}
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
function setConfirm(message: string, onConfirm: () => void) {
setState('prompt', { kind: 'confirm', message, onConfirm })
}
/** Open the pager overlay (long slash output: /status, /logs, …). */
function openPager(title: string, text: string) {
setState('pager', { title, text: stripAnsi(text) })
}
/** Close the pager overlay. */
function closePager() {
setState('pager', undefined)
}
/** Open the session switcher with the given session rows (/sessions, /resume). */
function openSwitcher(sessions: SessionItem[]) {
setState('switcher', sessions)
}
/** Close the session switcher. */
function closeSwitcher() {
setState('switcher', undefined)
}
/** Open the generic picker (model picker, skills hub, …). */
function openPicker(picker: PickerState) {
setState('picker', picker)
}
/** Close the generic picker. */
function closePicker() {
setState('picker', undefined)
}
/** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */
function setHint(text: string | undefined): void {
setState('hint', text)
}
/** Merge a session-info patch into the chrome state (status bar — item 14). */
function applyInfo(raw: { readonly [k: string]: unknown }): void {
const patch = readInfoPatch(raw)
if (Object.keys(patch).length) setState('info', prev => ({ ...prev, ...patch }))
}
/** Set / clear the live completion candidates (composer dropdown). `from` is the
* input char offset an accepted item replaces from (slash-arg / @-mention splice). */
function setCompletions(items: CompletionItem[], from = 0) {
setState('completions', items.length ? items : undefined)
setState('completionFrom', items.length ? Math.max(0, from) : 0)
}
function clearCompletions() {
setState('completions', undefined)
setState('completionFrom', 0)
}
/** 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)
// Clear any transient status: on a recovery-respawn ready this drops the
// lingering 'gateway recovering (attempt N)…' line; no-op on first connect.
setState('status', undefined)
setSkin(event.payload?.skin)
break
case 'skin.changed':
setSkin(event.payload)
break
case 'session.info':
applyInfo(event.payload)
break
case 'message.start':
setState('status', undefined)
setState('info', prev => ({ ...prev, running: true }))
setState(
produce(draft => {
draft.messages.push({ role: 'assistant', text: '', parts: [], streaming: true })
capMessages(draft)
})
)
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 = liveAssistant(draft, true)
if (live) appendPart(live, 'text', text)
})
)
break
}
case 'message.complete':
setState(
produce(draft => {
// complete-only gateways may send `message.complete{text}` with no prior
// start/delta → create the turn so the final text isn't dropped.
const finalText = event.payload?.text
const live = liveAssistant(draft, true) ?? (finalText ? ensureAssistant(draft) : undefined)
if (!live) return
// If no deltas arrived (complete-only gateways), seed the full text once.
const hasText = (live.parts ?? []).some(p => p.type === 'text' && p.text.length > 0)
if (finalText && !hasText) appendPart(live, 'text', finalText)
live.streaming = false
})
)
setState('status', undefined)
setState('info', prev => ({ ...prev, running: false }))
// message.complete carries the latest usage/context — refresh the bar.
if (event.payload) applyInfo(event.payload)
break
// thinking.delta / status.update are the TRANSIENT busy indicator (kaomoji
// face/verb) — route them to the status line, NOT the transcript (gotcha: Ink
// shows these as a FaceTicker, not message content).
case 'thinking.delta':
case 'status.update': {
const text = event.payload?.text ?? ''
if (text) setState('status', text)
break
}
// reasoning.delta is the model's actual reasoning — a (dim) transcript part.
case 'reasoning.delta': {
const text = event.payload?.text ?? ''
if (!text) break
setState(
produce(draft => {
appendPart(ensureAssistant(draft), 'reasoning', text)
})
)
break
}
case 'tool.start': {
const id = readStr(event.payload, 'tool_id')
if (!id) break
const name = readStr(event.payload, 'name') ?? 'tool'
// `context` = build_tool_preview's primary-arg line (always sent); `args_text`
// = redacted full-arg JSON (verbose mode only). Surfacing these is item 2.
const argsPreview = readStr(event.payload, 'context')
const argsText = readStr(event.payload, 'args_text')
setState(
produce(draft => {
const live = ensureAssistant(draft)
const part: ToolPartState = { type: 'tool', id, name, state: 'running' }
if (argsPreview) part.argsPreview = argsPreview
if (argsText) part.argsText = argsText
;(live.parts ??= []).push(part)
})
)
break
}
case 'tool.complete': {
const id = readStr(event.payload, 'tool_id')
if (!id) break
const name = readStr(event.payload, 'name')
const error = readStr(event.payload, 'error')
const summary = readStr(event.payload, 'summary')
// Peel the gateway's "[showing verbose tail; omitted …]" label (item 2) before
// envelope-stripping, so the body is clean and the note renders tidily.
const { body: rawBody, omittedNote } = stripOmittedNote(readStr(event.payload, 'result_text') ?? summary ?? '')
const resultText = stripToolEnvelope(rawBody)
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
// `args` (full dict) is always sent; stringify as the expanded-view args
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
const argsObj = event.payload['args']
const duration = readOptNum(event.payload, 'duration_s')
setState(
produce(draft => {
let part = findToolPart(draft, id)
if (!part) {
// complete without a matching start — append a settled tool part.
part = { type: 'tool', id, name: name ?? 'tool', state: 'running' }
;(ensureAssistant(draft).parts ??= []).push(part)
}
part.state = 'complete'
part.lineCount = lineCount
if (name) part.name = name
if (resultText) part.resultText = resultText
if (summary) part.summary = summary
if (error) part.error = error
if (duration !== undefined) part.duration = duration
if (omittedNote) part.omittedNote = omittedNote
// argsPreview (from tool.start `context`) is intentionally NOT overwritten.
if (!part.argsText && argsObj && typeof argsObj === 'object') {
try {
part.argsText = JSON.stringify(argsObj, null, 2)
} catch {
/* unstringifiable args — leave unset */
}
}
})
)
break
}
// ── blocking prompts (spec §8 #6 — unhandled = the agent deadlocks) ──
case 'clarify.request':
setState('prompt', {
kind: 'clarify',
question: event.payload.question ?? '',
// decoded choices are readonly — copy to the store's mutable string[]
choices: event.payload.choices ? [...event.payload.choices] : null,
requestId: event.payload.request_id
})
break
case 'approval.request':
setState('prompt', { kind: 'approval', command: event.payload.command, description: event.payload.description })
break
case 'sudo.request':
setState('prompt', { kind: 'sudo', requestId: event.payload.request_id })
break
case 'secret.request':
setState('prompt', {
kind: 'secret',
envVar: event.payload.env_var,
prompt: event.payload.prompt,
requestId: event.payload.request_id
})
break
// ── subagents (agents dashboard) — track the delegation tree by id ──
case 'subagent.spawn_requested':
case 'subagent.start':
case 'subagent.thinking':
case 'subagent.tool':
case 'subagent.progress':
case 'subagent.complete': {
const id = readStr(event.payload, 'subagent_id')
if (!id) break
setState(
produce(draft => {
let sa = draft.subagents.find(s => s.id === id)
if (!sa) {
sa = { depth: readNum(event.payload, 'depth'), goal: '', id, status: 'running' }
draft.subagents.push(sa)
}
const goal = readStr(event.payload, 'goal')
if (goal) sa.goal = goal
const model = readStr(event.payload, 'model')
if (model) sa.model = model
const parent = readStr(event.payload, 'parent_id')
if (parent) sa.parentId = parent
const summary = readStr(event.payload, 'summary')
if (summary) sa.summary = summary
const tool = readStr(event.payload, 'tool_name')
if (tool) sa.lastTool = tool
sa.status = readStr(event.payload, 'status') ?? subagentStatusFor(event.type)
// Live trace (item 15): a concise per-subagent activity log. Thinking
// deltas update a transient `thought` (not appended — they'd flood).
const text = readStr(event.payload, 'text')
const trace = (sa.trace ??= [])
if (event.type === 'subagent.start') trace.push(`${goal ?? sa.goal ?? 'started'}`)
else if (event.type === 'subagent.tool' && tool) trace.push(`${tool}${text ? `${text}` : ''}`)
else if (event.type === 'subagent.progress' && text) trace.push(text)
else if (event.type === 'subagent.complete') trace.push(`${summary ?? 'done'}`)
else if (event.type === 'subagent.thinking' && text) sa.thought = text
if (trace.length > SUBAGENT_TRACE_LIMIT) trace.splice(0, trace.length - SUBAGENT_TRACE_LIMIT)
})
)
break
}
// ── gateway lifecycle / transport errors (auto-heal foundations) ──
// The child exited mid-turn. THE key bug fix: clear the frozen `running`
// spinner (no message.complete will ever arrive for the lost reply), tell
// the user their in-flight reply was lost, and show a recovering status.
case 'gateway.exited': {
setState('info', prev => ({ ...prev, running: false }))
// Neutral status: we don't ALWAYS recover (budget exhaustion). The
// "recovering…" wording now comes from the gateway.recovering case,
// which fires only when a respawn is actually scheduled.
setState('status', 'gateway exited')
const reason = event.payload?.reason
const base = 'gateway exited — recovering your session (any in-flight reply was lost)'
pushSystem(reason ? `${base}: ${reason}` : base)
break
}
// A respawn+resume attempt is in flight — reflect the attempt in the status.
case 'gateway.recovering': {
const attempt = event.payload?.attempt
setState('status', attempt ? `gateway recovering (attempt ${attempt})…` : 'gateway recovering…')
break
}
// Collect stderr into a bounded ring (NOT the transcript) — see stderrRing.
case 'gateway.stderr': {
stderrRing.push(event.payload.line)
if (stderrRing.length > STDERR_RING_LIMIT) stderrRing.splice(0, stderrRing.length - STDERR_RING_LIMIT)
break
}
// The gateway never reached `gateway.ready` — surface the failure with any
// stderr tail (payload is a loose Record; read defensively).
case 'gateway.start_timeout': {
const detail = readStr(event.payload, 'stderr') ?? readStr(event.payload, 'message') ?? stderrTail()
pushSystem(detail ? `gateway failed to start:\n${detail}` : 'gateway failed to start')
break
}
case 'gateway.protocol_error': {
const preview = event.payload?.preview
pushSystem(preview ? `gateway protocol error: ${preview}` : 'gateway protocol error')
break
}
case 'error': {
const message = event.payload?.message
pushSystem(message ? `error: ${message}` : 'error')
break
}
// Other event types (chrome) are reduced in later phases; unhandled members
// are intentionally ignored here.
}
}
/** Clear the active blocking prompt (after it's answered/cancelled). */
function clearPrompt(): void {
setState('prompt', undefined)
}
// ── resume hydrate (opencode sync-v2): buffer live events while the snapshot
// loads, then replace history + replay the buffer in order. Split into begin/
// commit so the buffer can span an async `session.resume` RPC.
/** Start buffering live events (call BEFORE the async resume RPC). Idempotent. */
function beginBuffer(): void {
if (!buffering) buffering = []
}
/** Replace history with the resume snapshot, then replay events buffered meanwhile. */
function commitSnapshot(snapshot: Message[]): void {
// Slice to the cap BEFORE the first setState, not after. Yoga (WASM) layout
// memory is grow-only, so even a TRANSIENT mount of an over-cap resume
// snapshot would permanently ratchet the high-water mark — a set-then-trim
// briefly hands the full fetched history to <For>. Pre-slicing guarantees
// resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered
// across the resume RPC, replayed below, self-cap via capMessages per push.)
const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot
setState('messages', capped)
// A resume is a fresh view → SET (not accumulate) the dropped count to what the
// snapshot slice hid, so the notice reflects this session. Live pushes add to it.
setState('dropped', snapshot.length - capped.length)
const pending = buffering ?? []
buffering = null
for (const event of pending) applyNow(event)
}
/** Synchronous convenience: buffer → load → commit (used by tests). */
function hydrate(loadSnapshot: () => Message[]): void {
beginBuffer()
commitSnapshot(loadSnapshot())
}
/**
* Map the loose `startup.catalog` response into the typed Catalog (item 9).
* Decoded ONCE via `CatalogSchema` (decode-at-boundary); garbage decodes to
* `Option.none` → the catalog is left unset rather than crashing the panel.
*/
function setCatalog(raw: unknown): void {
const decoded = decodeCatalog(raw)
if (Option.isNone(decoded)) return
setState('catalog', catalogFrom(decoded.value))
}
function setSessionId(sid: string | undefined): void {
setState('sessionId', sid)
}
return {
state,
apply,
pushUser,
pushSystem,
setCatalog,
setSessionId,
clearTranscript,
setConfirm,
openPager,
closePager,
openSwitcher,
closeSwitcher,
openPicker,
closePicker,
setCompletions,
clearCompletions,
applyInfo,
setHint,
openDashboard,
closeDashboard,
hydrate,
beginBuffer,
commitSnapshot,
duplicate,
clearPrompt
} as const
}
export type SessionStore = ReturnType<typeof createSessionStore>
+502
View File
@@ -0,0 +1,502 @@
/**
* 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.
*/
import { FALSE_RE, TRUE_RE } from './env.ts'
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)
const hex = m?.[1]
if (!hex) return null
const n = parseInt(hex, 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
// Indices are `% 6`, always within XTERM_6_LEVELS' bounds; `?? 0` only
// satisfies noUncheckedIndexedAccess and is never actually reached.
return [
XTERM_6_LEVELS[Math.floor(offset / 36) % 6] ?? 0,
XTERM_6_LEVELS[Math.floor(offset / 6) % 6] ?? 0,
XTERM_6_LEVELS[offset % 6] ?? 0
]
}
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 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
let rgb: [number, number, number] | null = null
if (HEX_6_RE.test(hex)) {
rgb = [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]
} else if (HEX_3_RE.test(hex)) {
// `charAt` always returns a string (vs index access, which is `string |
// undefined` under noUncheckedIndexedAccess); the regex guarantees 3 chars.
const r = hex.charAt(0)
const g = hex.charAt(1)
const b = hex.charAt(2)
rgb = [parseInt(r + r, 16), parseInt(g + g, 16), parseInt(b + b, 16)]
}
if (!rgb) return null
const [r, g, b] = rgb
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 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 ?? ''
)
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Pure text-shaping helpers for compact tool-result rendering (spec v4 §7 / §8).
* No OpenTUI/Solid imports — just string work, trivially unit-testable. Ported
* 1:1 from the React build's `engine/toolOutput.ts` (itself mirroring opencode's
* `util/collapse-tool-output.ts` + the gateway tool-result JSON-envelope unwrap).
*/
/** Result of collapsing tool output for the block render. */
export interface Collapsed {
lines: string[]
/** How many trailing lines were dropped (0 when nothing was hidden). */
hiddenLines: number
truncated: boolean
}
// CSI escape sequences (SGR colors, cursor, mouse). The gateway colors some
// slash/notice text with raw ANSI for the Ink TUI, which interprets it; the
// native `<text>` renders byte-for-byte, so those codes would leak as literal
// glyphs. Strip them on display (item 8).
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /[\u001b\u009b]\[[0-9;:?<>=]*[ -/]*[@-~]/g
/** Remove ANSI/SGR/mouse escape sequences so they don't render as literal text. */
export function stripAnsi(s: string): string {
return (s ?? '').replace(ANSI_CSI, '')
}
/** Truncate a single line to `width` columns, adding an ellipsis when cut. */
export function truncate(s: string, width: number): string {
const w = Math.max(1, width)
return s.length > w ? s.slice(0, Math.max(1, w - 1)) + '…' : s
}
/**
* Un-double-escape gateway output that arrived with LITERAL `\n`/`\t` escapes
* (some tool tails are repr'd, so newlines show as backslash-n — item 7 "ugly").
* Conservative: only un-escapes when literal `\n` sequences OUTNUMBER real
* newlines, so genuinely multi-line output (and code that legitimately contains
* the two chars `\` + `n`) is left untouched.
*/
export function normalizeOutput(text: string): string {
const real = (text.match(/\n/g) ?? []).length
const literal = (text.match(/\\n/g) ?? []).length
if (literal > real)
return text
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\t/g, ' ')
return text
}
/**
* Unwrap the gateway's tool-result JSON envelope so the view shows the actual
* output, not the wrapper. Many tools return
* `{"output": "...", "exit_code": 0, "error": null}`. If `raw` parses to such an
* object, return its `output` (plus a compact error/exit suffix when the command
* failed); otherwise return `raw` unchanged. (Gotcha §8 — strip the envelope.)
*/
/**
* When the gateway tail-caps a LARGE result it serialises the whole
* `{"output": "...", "exit_code": 0, "error": null}` envelope first, so the
* surviving tail ends mid-string with the envelope close (`…", "exit_code": 0,
* "error": null}`) — and, if the head survived, opens with `{"output": "`. The
* fragment can't be JSON.parsed, so peel those affixes off conservatively (only
* the exact gateway shape; real output won't end this way). Item 2 polish.
*/
const ENVELOPE_HEAD = /^\s*\{\s*"output"\s*:\s*"/
const ENVELOPE_TAIL = /"\s*,\s*"exit_code"\s*:\s*-?\d+(?:\s*,\s*"error"\s*:\s*(?:null|"(?:[^"\\]|\\.)*"))?\s*\}\s*$/
function unwrapEnvelopeFragment(s: string): string {
const tail = ENVELOPE_TAIL.test(s)
const head = ENVELOPE_HEAD.test(s)
if (!tail && !head) return s
return s.replace(ENVELOPE_HEAD, '').replace(ENVELOPE_TAIL, '')
}
export function stripToolEnvelope(raw: string): string {
const s = (raw ?? '').trim()
if (!s.startsWith('{')) return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
try {
const parsed: unknown = JSON.parse(s)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && 'output' in parsed) {
const obj = parsed as Record<string, unknown>
let out = typeof obj.output === 'string' ? obj.output : JSON.stringify(obj.output, null, 2)
const err = obj.error
const code = obj.exit_code
if (typeof err === 'string' && err) out += `\n[error] ${err}`
else if (typeof code === 'number' && code !== 0) out += `\n[exit ${code}]`
return normalizeOutput(out)
}
} catch {
// not parseable as a whole — maybe a tail-capped envelope fragment
}
return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
}
/**
* The gateway caps verbose tool output to a tail and PREFIXES a literal label
* (`tui_gateway/server.py:_cap_tui_verbose_text`):
* `[showing verbose tail; omitted 5 lines / 234 chars]\n<tail>`
* `[showing verbose tail; omitted 512 chars]\n<tail>`
* The raw label is neither useful nor pretty (item 2). Strip it off and hand the
* view a tidy `omittedNote` ("5 lines / 234 chars") to render as a dim affordance.
*/
export function stripOmittedNote(text: string): { body: string; omittedNote?: string } {
const s = (text ?? '').replace(/^\s+/, '')
const match = s.match(/^\[showing verbose tail; omitted (.+?)\]\n/)
if (!match) return { body: text ?? '' }
return { body: s.slice(match[0].length), omittedNote: match[1] ?? '' }
}
/**
* Collapse text to at most `maxLines` lines, each capped to `width` columns. The
* view renders an overflow marker from `hiddenLines`; this stays pure (no marker).
*/
export function collapseToolOutput(text: string, maxLines: number, width: number): Collapsed {
const all = (text ?? '').replace(/\s+$/, '').split('\n')
const limit = Math.max(1, maxLines)
const lines = all.slice(0, limit).map(l => truncate(l, width))
const hiddenLines = Math.max(0, all.length - lines.length)
return { hiddenLines, lines, truncated: hiddenLines > 0 }
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Assistant-text extraction helpers (the /copy command's logic). Pure functions:
* pull the answer text out of a live (parts) or settled (.text) assistant turn,
* excluding reasoning/tool parts; pick the n-th newest assistant response.
*/
import { describe, expect, test } from 'vitest'
import { assistantResponses, messageText, nthAssistantResponse } from '../logic/copy.ts'
import type { Message } from '../logic/store.ts'
describe('messageText', () => {
test('a live parts turn concatenates text parts; excludes reasoning/tool', () => {
const m: Message = {
role: 'assistant',
text: '',
parts: [
{ type: 'reasoning', id: 'p1', text: 'thinking…' },
{ type: 'text', id: 'p2', text: 'Hello' },
{ type: 'tool', id: 't1', name: 'bash', state: 'complete', resultText: 'ran' },
{ type: 'text', id: 'p3', text: ' world' }
]
}
expect(messageText(m)).toBe('Hello world')
})
test('trims surrounding whitespace from concatenated text parts', () => {
const m: Message = {
role: 'assistant',
text: '',
parts: [{ type: 'text', id: 'p1', text: ' spaced ' }]
}
expect(messageText(m)).toBe('spaced')
})
test('a settled/resumed turn (no parts) returns .text', () => {
const m: Message = { role: 'assistant', text: 'resumed answer' }
expect(messageText(m)).toBe('resumed answer')
})
test('empty parts array falls back to .text', () => {
const m: Message = { role: 'assistant', text: 'flat body', parts: [] }
expect(messageText(m)).toBe('flat body')
})
})
describe('assistantResponses', () => {
test('picks only assistant rows, newest-first, non-empty', () => {
const messages: Message[] = [
{ role: 'system', text: 'welcome' },
{ role: 'user', text: 'hi' },
{ role: 'assistant', text: 'first reply' },
{ role: 'user', text: 'and?' },
{ role: 'assistant', text: '', parts: [{ type: 'text', id: 'p1', text: 'second reply' }] }
]
expect(assistantResponses(messages)).toEqual(['second reply', 'first reply'])
})
test('skips assistant rows that resolve to empty text', () => {
const messages: Message[] = [
{ role: 'assistant', text: 'kept' },
{ role: 'assistant', text: '', parts: [{ type: 'reasoning', id: 'r1', text: 'only thinking' }] }
]
expect(assistantResponses(messages)).toEqual(['kept'])
})
test('empty messages → []', () => {
expect(assistantResponses([])).toEqual([])
})
})
describe('nthAssistantResponse', () => {
const messages: Message[] = [
{ role: 'assistant', text: 'oldest' },
{ role: 'user', text: 'q' },
{ role: 'assistant', text: 'newest' }
]
test('n=1 is the last assistant response', () => {
expect(nthAssistantResponse(messages, 1)).toBe('newest')
})
test('n=2 is the previous assistant response', () => {
expect(nthAssistantResponse(messages, 2)).toBe('oldest')
})
test('n past the end → undefined', () => {
expect(nthAssistantResponse(messages, 3)).toBeUndefined()
})
test('no assistant responses → undefined', () => {
expect(nthAssistantResponse([{ role: 'user', text: 'hi' }], 1)).toBeUndefined()
expect(nthAssistantResponse([], 1)).toBeUndefined()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest'
import { envFlag } from '../logic/env.ts'
describe('envFlag', () => {
test('recognizes truthy values regardless of case/whitespace', () => {
for (const v of ['1', 'true', 'yes', 'on', 'TRUE', 'Yes', ' on ']) {
expect(envFlag(v, false)).toBe(true)
}
})
test('recognizes falsy values regardless of case/whitespace', () => {
for (const v of ['0', 'false', 'no', 'off', 'FALSE', 'No', ' off ']) {
expect(envFlag(v, true)).toBe(false)
}
})
test('returns fallback when unset', () => {
expect(envFlag(undefined, true)).toBe(true)
expect(envFlag(undefined, false)).toBe(false)
expect(envFlag('', true)).toBe(true)
expect(envFlag(' ', false)).toBe(false)
})
test('returns fallback for unrecognized garbage', () => {
expect(envFlag('maybe', true)).toBe(true)
expect(envFlag('maybe', false)).toBe(false)
expect(envFlag('2', true)).toBe(true)
expect(envFlag('enabled', false)).toBe(false)
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* Phase 0 boundary test (spec v4 §5 Layer 1). Exercises the GatewayService shape
* through the FakeGateway layer using @effect/vitest's `it.effect`: subscribe
* receives emitted events; request records the call. Proves the Effect<->Solid
* seam (subscribe) and the typed request path compile + run.
*
* `it.effect` runs the program in a scoped test runtime (TestClock + TestConsole
* provided automatically), replacing the old hand-rolled ManagedRuntime shim.
* The fake layer carries per-test controller state (we assert `controller.calls`),
* so it's provided locally — the testing guide's allowed one-off, not a shared
* `layer(...)` group.
*/
import { assert, describe, it } from '@effect/vitest'
import { Effect } from 'effect'
import { GatewayService } from '../boundary/gateway/GatewayService.ts'
import type { GatewayEvent } from '../boundary/schema/GatewayEvent.ts'
import { fakeGatewayLayerWith, makeFakeGateway } from '../entry/fakeGateway.ts'
describe('GatewayService via FakeGateway (Phase 0)', () => {
it.effect('subscribe receives emitted events; request records the call', () => {
const controller = makeFakeGateway('sess-123')
const received: GatewayEvent[] = []
return Effect.gen(function* () {
const gateway = yield* GatewayService
const unsubscribe = yield* gateway.subscribe(event => received.push(event))
// Emit after subscribing (synchronous fan-out in the fake).
controller.emit({ type: 'gateway.ready' })
controller.emit({ type: 'message.start' })
yield* gateway.request('prompt.submit', { text: 'hi' })
unsubscribe()
controller.emit({ type: 'message.complete' }) // dropped: unsubscribed
assert.strictEqual(gateway.sessionId(), 'sess-123')
assert.deepStrictEqual(
received.map(e => e.type),
['gateway.ready', 'message.start']
)
assert.deepStrictEqual(controller.calls, [{ method: 'prompt.submit', params: { text: 'hi' } }])
}).pipe(Effect.provide(fakeGatewayLayerWith(controller)))
})
})
@@ -0,0 +1,76 @@
/**
* Recovery-budget policy test (LOGIC side, pure). The crash-loop bound: attempts
* are capped within a sliding window, stale attempts are pruned, and recovery is
* refused with no session. Plus opencode-style exponential backoff (1s→30s cap).
*/
import { describe, expect, test } from 'vitest'
import {
backoffMs,
GATEWAY_RECOVERY_LIMIT,
GATEWAY_RECOVERY_WINDOW_MS,
planGatewayRecovery
} from '../logic/gatewayRecovery.ts'
describe('planGatewayRecovery — crash-loop budget', () => {
test('allows GATEWAY_RECOVERY_LIMIT attempts within the window, refuses the next', () => {
const sid = 'sess-1'
let attempts: number[] = []
const now = 1_000_000
// The first LIMIT exits all recover, each recording its timestamp.
for (let i = 0; i < GATEWAY_RECOVERY_LIMIT; i++) {
const plan = planGatewayRecovery(sid, null, attempts, now + i)
expect(plan.recover).toBe(true)
expect(plan.sid).toBe(sid)
attempts = plan.attempts
}
expect(attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
// The (LIMIT+1)th within the window is refused; attempts are NOT extended.
const refused = planGatewayRecovery(sid, null, attempts, now + GATEWAY_RECOVERY_LIMIT)
expect(refused.recover).toBe(false)
expect(refused.attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
})
test('prunes attempts older than GATEWAY_RECOVERY_WINDOW_MS, freeing the budget', () => {
const sid = 'sess-1'
const now = 1_000_000
// Three stale attempts (all outside the window) + one fresh.
const stale = [now - GATEWAY_RECOVERY_WINDOW_MS - 5, now - GATEWAY_RECOVERY_WINDOW_MS - 4, now - 30_000]
const plan = planGatewayRecovery(sid, null, stale, now)
// The two truly-stale ones are pruned; the in-window one survives + `now` added.
expect(plan.recover).toBe(true)
expect(plan.attempts).toEqual([now - 30_000, now])
})
test('refuses recovery when there is no session id (live nor recover)', () => {
const plan = planGatewayRecovery(null, null, [], 1_000_000)
expect(plan.recover).toBe(false)
expect(plan.sid).toBeNull()
expect(plan.attempts).toEqual([])
})
test('falls back to the recoverSid when the live sid was already cleared', () => {
const plan = planGatewayRecovery(null, 'pending-sess', [], 1_000_000)
expect(plan.recover).toBe(true)
expect(plan.sid).toBe('pending-sess')
})
})
describe('backoffMs — exponential delay (1s→30s cap)', () => {
test('doubles per attempt (1-based) and caps at 30000ms', () => {
expect(backoffMs(1)).toBe(1000)
expect(backoffMs(2)).toBe(2000)
expect(backoffMs(3)).toBe(4000)
expect(backoffMs(4)).toBe(8000)
expect(backoffMs(5)).toBe(16000)
expect(backoffMs(6)).toBe(30000) // 32000 clamped to the cap
expect(backoffMs(10)).toBe(30000) // stays at the cap
})
test('clamps a non-positive attempt to the first delay', () => {
expect(backoffMs(0)).toBe(1000)
expect(backoffMs(-3)).toBe(1000)
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* Prompt history (item 6) — pure cursor-cycling behaviour, no filesystem.
* Up walks older, Down walks newer back to the stashed draft; push dedupes a
* consecutive duplicate, persists, and resets the cursor; an edit (reset) puts
* the next Up back at the newest. Per-directory file persistence is exercised
* only via the injected `persist` sink here.
*/
import { describe, expect, test } from 'vitest'
import { createPromptHistory } from '../logic/history.ts'
describe('prompt history — cursor cycling', () => {
test('Up walks older entries, Down walks back to the live draft', () => {
const h = createPromptHistory({ initial: ['first', 'second', 'third'] })
// start typing a draft, then press Up
expect(h.prev('draft')).toBe('third')
expect(h.prev('draft')).toBe('second')
expect(h.prev('draft')).toBe('first')
expect(h.prev('draft')).toBe('first') // clamped at the oldest
// Down walks newer, then restores the stashed draft at the bottom
expect(h.next()).toBe('second')
expect(h.next()).toBe('third')
expect(h.next()).toBe('draft')
expect(h.next()).toBeNull() // already at the bottom
})
test('push appends, dedupes a consecutive duplicate, persists, resets cursor', () => {
const persisted: string[] = []
const h = createPromptHistory({ initial: ['a'], persist: t => persisted.push(t) })
h.push('b')
h.push('b') // consecutive duplicate — not stored again
h.push('c')
expect(h.entries()).toEqual(['a', 'b', 'c'])
expect(persisted).toEqual(['b', 'c'])
// after push the cursor is at the bottom → Up returns the newest
expect(h.prev('')).toBe('c')
})
test('reset returns the cursor to the bottom (called on edit)', () => {
const h = createPromptHistory({ initial: ['x', 'y'] })
expect(h.prev('')).toBe('y')
expect(h.prev('')).toBe('x')
h.reset() // user edited the buffer
expect(h.prev('newdraft')).toBe('y') // next Up starts from the newest again
})
test('empty history: prev/next are inert', () => {
const h = createPromptHistory()
expect(h.prev('draft')).toBeNull()
expect(h.next()).toBeNull()
})
test('max cap drops the oldest entries', () => {
const h = createPromptHistory({ max: 2 })
h.push('1')
h.push('2')
h.push('3')
expect(h.entries()).toEqual(['2', '3'])
})
})
+95
View File
@@ -0,0 +1,95 @@
/**
* test/lib/render.ts — headless renderable verification helpers (spec v4 §5
* Layer 2). Wraps the Solid binding's `testRender` + the settle dance.
*
* Settling needs care: Solid mounts async; a `<scrollbox>` needs a couple of
* passes to measure content + apply stickyStart; and the native `<markdown>`
* (Tree-sitter) tokenizes ASYNCHRONOUSLY — a plain `renderOnce` loop captures
* before its text paints. So we `flush()` (wait until scheduled rendering
* settles) between passes, and `captureFrame` can wait for specific content via
* `until` (retries with `waitForFrame`) for markdown-bearing frames.
*
* `exitOnCtrlC: false` is forced (gotcha §8 #7 — the test renderer defaults true
* and would tear down on the first simulated Ctrl+C, blanking later frames).
*
* Keymap (Phase 3): overlays/prompts register close layers via `@opentui/keymap`,
* whose hooks throw without a `<KeymapProvider>`. The entry provides one in the
* real app; here we provide a test keymap built from the test renderer (read via
* `useRenderer()` inside the tree) so headless mounts of those views work.
*/
import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
import { KeymapProvider } from '@opentui/keymap/solid'
import { testRender, useRenderer } from '@opentui/solid'
import type { JSX } from '@opentui/solid'
import { createMemo } from 'solid-js'
/** Wrap a node in a KeymapProvider whose keymap is bound to the test renderer. */
function withKeymap(node: () => JSX.Element): () => JSX.Element {
return () => {
const renderer = useRenderer()
const keymap = createMemo(() => createDefaultOpenTuiKeymap(renderer))
return KeymapProvider({
keymap: keymap(),
get children() {
return node()
}
})
}
}
export interface RenderProbe {
readonly frame: () => string
readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise<string>
readonly resize: (width: number, height: number) => void
readonly destroy: () => void
}
/** Mount a Solid node headlessly and return a probe with a settled first frame. */
export async function renderProbe(
node: () => JSX.Element,
options?: { width?: number; height?: number }
): Promise<RenderProbe> {
const setup = await testRender(withKeymap(node), {
width: options?.width ?? 80,
height: options?.height ?? 24,
exitOnCtrlC: false
})
// renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure,
// Tree-sitter markdown tokenization) that a single sync pass would miss. The
// native `<markdown internalBlockMode="top-level">` commits blocks over several
// native frames, so settle to visual idle too (best-effort).
await setup.renderOnce()
await setup.flush()
await setup.waitForVisualIdle?.()
await setup.renderOnce()
await setup.flush()
return {
frame: () => setup.captureCharFrame(),
waitForFrame: predicate => setup.waitForFrame(predicate),
resize: (width, height) => setup.resize(width, height),
destroy: () => setup.renderer.destroy?.()
}
}
/**
* Mount, capture one settled frame, tear down. When `until` is given (string or
* RegExp), waits for the frame to contain/match it first — use for async
* markdown content that may not be painted on the first settled pass.
*/
export async function captureFrame(
node: () => JSX.Element,
options?: { width?: number; height?: number; until?: string | RegExp }
): Promise<string> {
const probe = await renderProbe(node, options)
try {
const until = options?.until
if (until !== undefined) {
const match = (frame: string) => (typeof until === 'string' ? frame.includes(until) : until.test(frame))
return await probe.waitForFrame(match)
}
return probe.frame()
} finally {
probe.destroy()
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 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 (no Bun):
* node scripts/build.mjs src/test/liveGateway.smoke.ts .out
* node --experimental-ffi --no-warnings .out/liveGateway.smoke.js
* (or `bash scripts/acceptance.sh`, which runs it as the transport gate).
*/
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()
+111
View File
@@ -0,0 +1,111 @@
/**
* Log hardening (boundary/log.ts):
* - safeStringify never throws on a circular ref / BigInt / hostile toJSON,
* so one bad `data` payload can't flip `fileBroken` and kill file logging.
* - the NDJSON file rotates by size (counter-driven), keeping disk bounded.
* Rotation is exercised with a real temp dir; since LOG_MAX_BYTES (5 MiB) is not
* exported, we seed the live file above the cap so the next write must rotate.
*/
import { describe, expect, test } from 'vitest'
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Log, safeStringify } from '../boundary/log.ts'
describe('safeStringify', () => {
test('handles a circular object without throwing', () => {
const a: Record<string, unknown> = { name: 'a' }
a.self = a
const out = safeStringify(a)
expect(typeof out).toBe('string')
expect(out).toContain('[Circular]')
expect(out).toContain('"name":"a"')
})
test('handles a BigInt without throwing', () => {
const out = safeStringify({ big: 10n, nested: { x: 9007199254740993n } })
expect(typeof out).toBe('string')
expect(out).toContain('"10n"')
expect(out).toContain('"9007199254740993n"')
})
test('handles a mixed circular + BigInt payload', () => {
const node: Record<string, unknown> = { id: 1n }
node.parent = node
expect(() => safeStringify({ node, list: [1n, 2n] })).not.toThrow()
})
test('degrades a hostile toJSON to a placeholder instead of throwing', () => {
const hostile = {
toJSON() {
throw new Error('boom')
}
}
let out = ''
expect(() => {
out = safeStringify(hostile)
}).not.toThrow()
expect(typeof out).toBe('string')
})
test('round-trips a plain object identically to JSON.stringify', () => {
const v = { a: 1, b: 'two', c: [3, 4], d: null }
expect(safeStringify(v)).toBe(JSON.stringify(v))
})
})
describe('Log file logging survives a poison payload', () => {
test('a circular/BigInt data field still writes a line and keeps filePath', () => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-log-poison-'))
const file = join(dir, 'opentui-v2.log')
try {
const log = new Log(file, 'debug')
const circular: Record<string, unknown> = {}
circular.self = circular
log.info('test', 'with circular', circular)
log.info('test', 'with bigint', { n: 42n })
// file logging must NOT be broken by the poison payloads
expect(log.filePath).toBe(file)
const lines = readFileLines(file)
expect(lines.length).toBe(2)
expect(lines[0]).toContain('[Circular]')
expect(lines[1]).toContain('42n')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('Log file rotation', () => {
test('rotates the live file once it crosses the byte cap', () => {
const dir = mkdtempSync(join(tmpdir(), 'hermes-log-rotate-'))
const file = join(dir, 'opentui-v2.log')
try {
// Seed the live file ABOVE the 5 MiB cap so the very next write rotates.
writeFileSync(file, 'x'.repeat(5 * 1024 * 1024 + 10) + '\n')
const log = new Log(file, 'debug')
log.info('test', 'first write after seed') // crosses the cap -> rotates
log.info('test', 'second write on fresh file')
const names = readdirSync(dir).sort()
expect(names).toContain('opentui-v2.log')
expect(names).toContain('opentui-v2.log.1') // the seeded oversized file
// The fresh live file holds the post-rotation writes, not the seed.
const live = readFileLines(file)
expect(live.length).toBe(2)
expect(live[0]).toContain('first write after seed')
// The rotated-out file is the big seed.
const rotated = readFileLines(`${file}.1`)
expect(rotated[0]?.startsWith('xxxx')).toBe(true)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
function readFileLines(path: string): string[] {
// trailing newline produces an empty tail we drop
const text = readFileSync(path, 'utf8')
return text.split('\n').filter(line => line.length > 0)
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Pasted-text store test — add returns a placeholder, expand restores the real
* content, multiple pastes round-trip, unknown refs pass through, single-pass
* replace keeps a self-referential paste safe. (input polish.)
*/
import { describe, expect, test } from 'vitest'
import { createPasteStore, shouldPlaceholder } from '../logic/pastes.ts'
describe('createPasteStore', () => {
test('add returns a numbered placeholder with the line count', () => {
const s = createPasteStore()
expect(s.add('a\nb\nc')).toBe('[Pasted text #1 +3 lines]')
expect(s.add('single line')).toBe('[Pasted text #2]') // 1 line → no "+N lines"
})
test('expand restores the real content for each ref', () => {
const s = createPasteStore()
const p1 = s.add('FIRST\nblock')
const p2 = s.add('SECOND')
const input = `before ${p1} middle ${p2} after`
expect(s.expand(input)).toBe('before FIRST\nblock middle SECOND after')
})
test('unknown ref is left as-is (e.g. user typed it, or it was cleared)', () => {
const s = createPasteStore()
expect(s.expand('look [Pasted text #99] here')).toBe('look [Pasted text #99] here')
})
test('single-pass replace: a pasted block containing a ref literal is NOT re-expanded', () => {
const s = createPasteStore()
const p1 = s.add('code with [Pasted text #2] inside')
s.add('SHOULD-NOT-APPEAR')
// expanding the input replaces #1 with its content; the #2 inside that content
// is not re-scanned, so SHOULD-NOT-APPEAR never leaks in.
expect(s.expand(`x ${p1}`)).toBe('x code with [Pasted text #2] inside')
})
test('clear drops stored pastes and resets ids', () => {
const s = createPasteStore()
const p = s.add('gone')
s.clear()
expect(s.expand(p)).toBe(p) // no longer expandable
expect(s.add('fresh')).toBe('[Pasted text #1]') // seq reset
})
test('shouldPlaceholder: ≥4 lines OR >400 chars', () => {
expect(shouldPlaceholder('a\nb\nc\nd')).toBe(true) // 4 lines
expect(shouldPlaceholder('a\nb\nc')).toBe(false) // 3 lines
expect(shouldPlaceholder('x'.repeat(401))).toBe(true) // long
expect(shouldPlaceholder('short')).toBe(false)
})
})
+332
View File
@@ -0,0 +1,332 @@
/**
* 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 'vitest'
import { createSessionStore } from '../logic/store.ts'
import { App } from '../view/App.tsx'
import { ThemeProvider } from '../view/theme.tsx'
import { captureFrame } from './lib/render.ts'
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()
seedHello(store)
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'ready', width: 60, height: 16 }
)
expect(frame).toContain('Hermes Agent') // default brand.name
expect(frame).toContain('ready')
expect(frame).toContain('Type your message') // composer placeholder (brand.welcome)
// Assistant text renders through the native markdown renderable (<code filetype="markdown">,
// drawUnstyledText:false → smooth live, but tree-sitter doesn't settle in the headless test
// renderer; markdown paint is verified in the live smoke). Assert the data reached the store:
const parts = store.state.messages.at(-1)?.parts ?? []
expect(parts.some(p => p.type === 'text' && p.text === 'Hi there, glitch!')).toBe(true)
})
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: 16 }
)
expect(frame).toContain('Zephyr')
expect(frame).not.toContain('Hermes Agent')
})
test('renders an inline tool part between text (ordered parts §7)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'Listing files:' } })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
store.apply({
type: 'tool.complete',
payload: { tool_id: 't1', result_text: '{"output":"alpha.txt\\nbeta.txt","exit_code":0}' }
})
store.apply({ type: 'message.complete' })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'terminal', width: 60, height: 16 }
)
expect(frame).toContain('terminal') // tool name (inline, between text blocks)
expect(frame).toContain('alpha.txt') // envelope-stripped output, block-rendered
expect(frame).not.toContain('exit_code') // the {output,exit_code} envelope is stripped
// the 'Listing files:' text part is markdown (live-rendered); assert it in the store:
const parts = store.state.messages.at(-1)?.parts ?? []
expect(parts.some(p => p.type === 'text' && p.text === 'Listing files:')).toBe(true)
})
test('a tool part shows its primary-arg preview + duration in the collapsed header (item 2)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal', context: 'ls -la src' } })
store.apply({
type: 'tool.complete',
payload: {
tool_id: 't1',
name: 'terminal',
args: { command: 'ls -la src' },
duration_s: 0.3,
result_text: 'alpha.txt\nbeta.txt'
}
})
store.apply({ type: 'message.complete' })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'ls -la src', width: 72, height: 16 }
)
expect(frame).toContain('terminal') // tool name
expect(frame).toContain('ls -la src') // primary-arg preview (item 2 — args now visible)
expect(frame).toContain('0.3s') // duration
expect(frame).toContain('(2 lines)') // output line count (collapsed)
})
test('a settled reasoning part collapses to a one-line "Thought: <title>" header (item 6)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'reasoning.delta', payload: { text: '**Weighing options**\n\nthe hidden body text here' } })
store.apply({ type: 'message.delta', payload: { text: 'Answer.' } })
store.apply({ type: 'message.complete' })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Thought', width: 72, height: 16 }
)
expect(frame).toContain('Thought') // settled → collapsed header label
expect(frame).toContain('Weighing options') // the **bold** title is surfaced
expect(frame).not.toContain('hidden body text') // collapsed → body not shown
})
test('an approval prompt replaces the composer (blocked) and renders the options', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'Delete temp dir' } })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Approval required', width: 72, height: 24 }
)
expect(frame).toContain('Approval required')
expect(frame).toContain('rm -rf /tmp/x') // the command under review
expect(frame).toContain('Approve once') // native <select> option
expect(frame).toContain('Deny')
expect(frame).not.toContain('Type your message') // composer is hidden while blocked
})
test('the pager overlay renders title + content and replaces the transcript/composer', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.pushUser('a previous message')
store.openPager('Status', 'status line one\nstatus line two\nstatus line three')
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Status', width: 72, height: 18 }
)
expect(frame).toContain('Status') // pager title
expect(frame).toContain('status line one') // paged content
expect(frame).toContain('Esc/q close') // pager footer hint
expect(frame).not.toContain('a previous message') // transcript replaced by the pager
expect(frame).not.toContain('Type your message') // composer hidden while the pager is open
})
test('the session switcher renders session rows and replaces the composer', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.openSwitcher([
{ id: 's1', title: 'First chat', preview: 'hi', messageCount: 5 },
{ id: 's2', title: 'Second chat', preview: 'yo', messageCount: 12 }
])
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Resume a session', width: 72, height: 18 }
)
expect(frame).toContain('Resume a session') // switcher header
expect(frame).toContain('First chat') // session row
expect(frame).toContain('Second chat')
expect(frame).not.toContain('Type your message') // composer hidden while switcher open
})
test('the composer shows a live slash-completions dropdown', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.setCompletions([
{ display: '/compact', meta: 'compress context', text: '/compact' },
{ display: '/clear', meta: '', text: '/clear' }
])
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: '/compact', width: 72, height: 18 }
)
expect(frame).toContain('/compact') // candidate
expect(frame).toContain('compress context') // its meta
expect(frame).toContain('Tab complete') // dropdown hint
})
test('the empty transcript shows the home hint (item 12)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Nous Research', width: 72, height: 20 }
)
// (theme-independent assertions — testRender reuses a global root, so a prior
// test's skin/brand can bleed; the real app has one store. The home hint's
// content is what matters here.)
expect(frame).toContain('Nous Research') // the tagline
expect(frame).toContain('to mention') // the input tips line
expect(frame).toContain('Ctrl+C to stop/quit')
})
test('the home screen shows a collapsible tools/skills/MCP catalog panel (item 9)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.setCatalog({
tools: { total: 42, toolsets: [{ name: 'core', count: 12 }] },
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
mcp: { servers: ['railway', 'beeper'] }
})
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: '42 tools', width: 72, height: 20 }
)
expect(frame).toContain('42 tools')
expect(frame).toContain('7 skills')
expect(frame).toContain('2 MCP') // mcp.servers.length
})
test('the status bar renders model · context% · cwd (item 14)', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'session.info',
payload: {
model: 'anthropic/claude-opus-4-8',
cwd: '/tmp/proj',
branch: 'main',
usage: { context_percent: 42 }
}
})
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'claude-opus', width: 72, height: 18 }
)
expect(frame).toContain('claude-opus-4-8') // model (provider prefix trimmed)
expect(frame).toContain('42%') // context usage percent
expect(frame).toContain('/tmp/proj') // cwd
expect(frame).toContain('main') // branch
})
test('the agents dashboard renders the subagent tree and replaces the transcript', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.pushUser('parent turn')
store.apply({
type: 'subagent.start',
payload: { subagent_id: 'a1', goal: 'research the topic', model: 'haiku', depth: 0 }
})
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
store.openDashboard()
const frame = await captureFrame(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ until: 'Agents', width: 72, height: 24 }
)
expect(frame).toContain('Agents') // dashboard header
expect(frame).toContain('research the topic') // subagent goal (list + detail header)
expect(frame).toContain('web_search') // last tool + live trace line (item 15)
expect(frame).toContain('select') // footer hint "↑↓ select"
expect(frame).not.toContain('parent turn') // transcript replaced by the dashboard
})
})
+64
View File
@@ -0,0 +1,64 @@
/**
* Resume mapper test (spec §1 lifecycle; gotcha §8 #5). The `session.resume`
* history maps into the store's Message[], folding tool rows ({name,context},
* NO text) into the preceding assistant turn's ordered parts so they render.
*/
import { describe, expect, test } from 'vitest'
import { mapResumeHistory } from '../logic/resume.ts'
describe('mapResumeHistory (Phase 4b)', () => {
test('maps user/assistant text + folds tool rows into the preceding assistant parts', () => {
const msgs = mapResumeHistory([
{ role: 'user', text: 'list files' },
{ role: 'assistant', text: 'Listing.' },
{ role: 'tool', name: 'terminal', context: 'ls -la' },
{ role: 'assistant', text: 'Done.' }
])
expect(msgs.map(m => m.role)).toEqual(['user', 'assistant', 'assistant'])
expect(msgs[0]).toMatchObject({ role: 'user', text: 'list files' })
const a1 = msgs[1]!
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
const tool = a1.parts![1]!
if (tool.type === 'tool') {
// context → argsPreview (same field as a live tool part, so it renders identically)
expect(tool).toMatchObject({ name: 'terminal', state: 'complete', argsPreview: 'ls -la' })
} else {
throw new Error('expected a folded tool part')
}
expect(msgs[2]).toMatchObject({ role: 'assistant', text: 'Done.' })
})
test('a tool row with no preceding assistant gets a standalone assistant holder', () => {
const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }])
expect(msgs).toHaveLength(1)
expect(msgs[0]!.role).toBe('assistant')
expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', argsPreview: 'foo.ts' })
})
test('folds result_text + args so resumed tools render collapsible like live (item 1)', () => {
const msgs = mapResumeHistory([
{ role: 'assistant', text: 'Running.' },
{
role: 'tool',
name: 'terminal',
context: 'ls /usr/bin',
args: { command: 'ls /usr/bin' },
result_text: '[showing verbose tail; omitted 90 chars]\n{"output":"a\\nb\\nc","exit_code":0}'
}
])
const tool = msgs[0]!.parts![1]!
if (tool.type !== 'tool') throw new Error('expected a folded tool part')
expect(tool.argsPreview).toBe('ls /usr/bin')
expect(tool.resultText).toBe('a\nb\nc') // label peeled + envelope stripped → collapsible
expect(tool.lineCount).toBe(3)
expect(tool.omittedNote).toBe('90 chars')
expect(tool.argsText).toContain('"command"')
})
test('ignores non-arrays and unknown roles', () => {
expect(mapResumeHistory(null)).toEqual([])
expect(mapResumeHistory([{ role: 'weird', text: 'x' }])).toEqual([])
})
})
+78
View File
@@ -0,0 +1,78 @@
/**
* 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 'vitest'
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('decodes gateway.exited with and without payload fields', () => {
const full = decode({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137, signal: 'SIGKILL' } })
expect(Option.isSome(full)).toBe(true)
if (Option.isSome(full) && full.value.type === 'gateway.exited') {
expect(full.value.payload?.reason).toBe('SIGKILL')
expect(full.value.payload?.code).toBe(137)
expect(full.value.payload?.signal).toBe('SIGKILL')
}
// payload is optional in full
const bare = decode({ type: 'gateway.exited' })
expect(Option.isSome(bare)).toBe(true)
if (Option.isSome(bare) && bare.value.type === 'gateway.exited') {
expect(bare.value.payload).toBeUndefined()
}
})
test('decodes gateway.recovering with and without payload fields', () => {
const full = decode({ type: 'gateway.recovering', payload: { attempt: 2, delay_ms: 2000 } })
expect(Option.isSome(full)).toBe(true)
if (Option.isSome(full) && full.value.type === 'gateway.recovering') {
expect(full.value.payload?.attempt).toBe(2)
expect(full.value.payload?.delay_ms).toBe(2000)
}
const bare = decode({ type: 'gateway.recovering' })
expect(Option.isSome(bare)).toBe(true)
if (Option.isSome(bare) && bare.value.type === 'gateway.recovering') {
expect(bare.value.payload).toBeUndefined()
}
})
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)
})
})
+316
View File
@@ -0,0 +1,316 @@
/**
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
* ladder (client → slash.exec → command.dispatch) against a fake SlashContext.
*/
import { describe, expect, test } from 'vitest'
import {
dispatchSlash,
mapCompletions,
parseSlash,
planCompletion,
readReplaceFrom,
type SlashContext
} from '../logic/slash.ts'
import type { PickerItem, SessionItem } from '../logic/store.ts'
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
describe('mapCompletions', () => {
test('maps complete.slash items → candidates (display/meta default)', () => {
expect(
mapCompletions({ items: [{ display: '/compact', meta: 'compress', text: '/compact' }, { text: '/details' }] })
).toEqual([
{ display: '/compact', meta: 'compress', text: '/compact' },
{ display: '/details', meta: '', text: '/details' }
])
expect(mapCompletions({ items: [] })).toEqual([])
expect(mapCompletions(null)).toEqual([])
})
})
describe('planCompletion (items 5 + 13)', () => {
test('a slash line → complete.slash with the full text (name AND args)', () => {
expect(planCompletion('/mod')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/mod' } })
// args too — the gateway completes e.g. /details section names
expect(planCompletion('/details thi')).toEqual({
from: 0,
method: 'complete.slash',
params: { text: '/details thi' }
})
})
test('a trailing path-like word → complete.path with that word + token start offset', () => {
expect(planCompletion('explain @src/fo')).toEqual({
from: 'explain '.length,
method: 'complete.path',
params: { word: '@src/fo' }
})
expect(planCompletion('cat ./rea')).toEqual({
from: 'cat '.length,
method: 'complete.path',
params: { word: './rea' }
})
expect(planCompletion('open ~/proj')).toEqual({
from: 'open '.length,
method: 'complete.path',
params: { word: '~/proj' }
})
})
test('plain prose / multiline → no completion', () => {
expect(planCompletion('just some words')).toBeNull()
expect(planCompletion('hello')).toBeNull()
expect(planCompletion('/cmd with\nnewline')).toBeNull()
})
})
describe('readReplaceFrom', () => {
test('reads gateway replace_from, falls back when absent/non-number', () => {
expect(readReplaceFrom({ items: [], replace_from: 9 }, 0)).toBe(9)
expect(readReplaceFrom({ items: [] }, 4)).toBe(4)
expect(readReplaceFrom({ replace_from: 'nope' }, 7)).toBe(7)
expect(readReplaceFrom(null, 2)).toBe(2)
})
})
describe('parseSlash', () => {
test('splits name + arg; rejects non-slash / empty', () => {
expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' })
expect(parseSlash('/model anthropic/claude')).toEqual({ name: 'model', arg: 'anthropic/claude' })
expect(parseSlash('hello')).toBeNull()
expect(parseSlash('/')).toBeNull()
})
})
interface Probe {
ctx: SlashContext
calls: Array<{ method: string; params: Record<string, unknown> }>
system: string[]
submitted: string[]
confirmed: Array<{ message: string; onConfirm: () => void }>
paged: Array<{ title: string; text: string }>
switched: SessionItem[][]
pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }>
quit: { value: boolean }
cleared: { value: boolean }
dashboard: { value: boolean }
copied: number[]
copyN: { value: (n: number) => boolean }
}
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
const calls: Probe['calls'] = []
const system: string[] = []
const submitted: string[] = []
const confirmed: Probe['confirmed'] = []
const paged: Probe['paged'] = []
const switched: Probe['switched'] = []
const pickers: Probe['pickers'] = []
const quit = { value: false }
const cleared = { value: false }
const dashboard = { value: false }
const copied: number[] = []
const copyN: Probe['copyN'] = { value: () => false }
const ctx: SlashContext = {
clearTranscript: () => (cleared.value = true),
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
copyResponse: n => {
copied.push(n)
return copyN.value(n)
},
listSessions: () => Promise.resolve(FAKE_SESSIONS),
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
openDashboard: () => (dashboard.value = true),
openPager: (title, text) => paged.push({ text, title }),
openPicker: p => pickers.push(p),
openSwitcher: sessions => switched.push(sessions),
pushSystem: text => system.push(text),
quit: () => (quit.value = true),
request: (method, params) => {
calls.push({ method, params })
return request(method, params)
},
sessionId: () => 'sid-1',
submit: text => submitted.push(text)
}
return {
calls,
cleared,
confirmed,
copied,
copyN,
ctx,
dashboard,
paged,
pickers,
quit,
submitted,
switched,
system
}
}
describe('dispatchSlash — client commands', () => {
test('/quit quits without hitting the gateway', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/quit', p.ctx)
expect(p.quit.value).toBe(true)
expect(p.calls).toHaveLength(0)
})
test('/clear opens a confirm; running onConfirm clears the transcript', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/clear', p.ctx)
expect(p.confirmed).toHaveLength(1)
expect(p.cleared.value).toBe(false)
p.confirmed[0]!.onConfirm()
expect(p.cleared.value).toBe(true)
})
test('/logs opens the pager with the recent ring lines', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/logs', p.ctx)
expect(p.paged[0]?.title).toBe('Logs')
expect(p.paged[0]?.text).toContain('session created')
})
test('/sessions (and /resume) open the switcher with session.list rows', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/sessions', p.ctx)
expect(p.switched).toHaveLength(1)
expect(p.switched[0]).toEqual(FAKE_SESSIONS)
const p2 = makeCtx(async () => ({}))
await dispatchSlash('/resume', p2.ctx)
expect(p2.switched).toHaveLength(1)
})
test('/model (bare) opens a picker of authenticated providers models; pick switches', async () => {
const p = makeCtx(async method => {
if (method === 'model.options')
return {
model: 'claude-sonnet-4.6',
providers: [
{
authenticated: true,
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
name: 'Anthropic',
slug: 'anthropic'
},
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
]
}
return { output: 'switched' }
})
await dispatchSlash('/model', p.ctx)
expect(p.pickers).toHaveLength(1)
expect(p.pickers[0]!.title).toBe('Switch model')
// only the authenticated provider's models; current is marked
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
// picking switches via slash.exec `model <name>`
p.pickers[0]!.onPick('claude-opus-4.6')
await Promise.resolve()
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
})
test('/model <name> switches directly without opening the picker', async () => {
const p = makeCtx(async () => ({ output: 'ok' }))
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
expect(p.pickers).toHaveLength(0)
expect(p.calls[0]).toEqual({
method: 'slash.exec',
params: { command: 'model anthropic/claude-opus-4.6', session_id: 'sid-1' }
})
})
test('/copy copies via copyResponse; no system line on success', async () => {
const p = makeCtx(async () => ({}))
p.copyN.value = () => true
await dispatchSlash('/copy', p.ctx)
expect(p.copied).toEqual([1])
expect(p.system).toHaveLength(0)
})
test('/copy 2 passes the n-th index through', async () => {
const p = makeCtx(async () => ({}))
p.copyN.value = () => true
await dispatchSlash('/copy 2', p.ctx)
expect(p.copied).toEqual([2])
})
test('/copy when nothing to copy pushes a system notice', async () => {
const p = makeCtx(async () => ({}))
p.copyN.value = () => false
await dispatchSlash('/copy', p.ctx)
expect(p.system).toContain('Nothing to copy yet.')
})
test('/agents (and /tasks) open the agents dashboard', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/agents', p.ctx)
expect(p.dashboard.value).toBe(true)
const p2 = makeCtx(async () => ({}))
await dispatchSlash('/tasks', p2.ctx)
expect(p2.dashboard.value).toBe(true)
})
test('/skills opens a picker flattened from skills.manage list', async () => {
const p = makeCtx(async method =>
method === 'skills.manage' ? { skills: { media: ['ffmpeg', 'whisper'], web: ['firecrawl'] } } : {}
)
await dispatchSlash('/skills', p.ctx)
expect(p.pickers).toHaveLength(1)
expect(p.pickers[0]!.title).toBe('Skills')
expect(p.pickers[0]!.items.map(i => i.value).sort()).toEqual(['ffmpeg', 'firecrawl', 'whisper'])
})
test('/help renders the gateway catalog', async () => {
const p = makeCtx(async method =>
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}
)
await dispatchSlash('/help', p.ctx)
expect(p.calls[0]?.method).toBe('commands.catalog')
expect(p.system.join('\n')).toContain('/model — switch model')
})
})
describe('dispatchSlash — server ladder', () => {
test('unknown command → slash.exec; SHORT output shown as a system line', async () => {
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
await dispatchSlash('/status', p.ctx)
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
expect(p.system).toContain('all good')
expect(p.paged).toHaveLength(0)
})
test('LONG slash.exec output opens the pager (titled by command)', async () => {
const longText = Array.from({ length: 6 }, (_, i) => `output line ${i}`).join('\n')
const p = makeCtx(async method => (method === 'slash.exec' ? { output: longText } : {}))
await dispatchSlash('/status', p.ctx)
expect(p.paged).toHaveLength(1)
expect(p.paged[0]?.title).toBe('Status')
expect(p.paged[0]?.text).toContain('output line 5')
expect(p.system).toHaveLength(0)
})
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
const p = makeCtx(async method => {
if (method === 'slash.exec') throw new Error('not a worker command')
if (method === 'command.dispatch') return { type: 'send', message: 'run the thing' }
return {}
})
await dispatchSlash('/dothing', p.ctx)
expect(p.calls.map(c => c.method)).toEqual(['slash.exec', 'command.dispatch'])
expect(p.submitted).toEqual(['run the thing'])
})
test('command.dispatch exec → system output', async () => {
const p = makeCtx(async method => {
if (method === 'slash.exec') throw new Error('reject')
return { type: 'exec', output: 'done' }
})
await dispatchSlash('/whatever', p.ctx)
expect(p.system).toContain('done')
})
})
+562
View File
@@ -0,0 +1,562 @@
/**
* Store test (spec v4 §5 Layer 3). Pure data behavior of the reducer: skin →
* theme, LRU dedup, hydrate-while-buffering (Phase 1); and the Phase 2b ordered
* `parts[]` model — text/tool interleave in one turn, tool start↔complete matched
* by id and updated IN PLACE, `{output,exit_code}` envelope stripped.
*/
import { afterEach, describe, expect, test } from 'vitest'
import { DEFAULT_THEME } from '../logic/theme.ts'
import { createSessionStore, type Message } from '../logic/store.ts'
describe('session store — theming / dedup / hydrate (Phase 1)', () => {
test('gateway.ready{skin} re-themes; default before', () => {
const store = createSessionStore()
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('skin.changed updates the theme live', () => {
const store = createSessionStore()
store.apply({ type: 'skin.changed', payload: { branding: { agent_name: 'Aurora' } } })
expect(store.state.theme.brand.name).toBe('Aurora')
})
test('LRU dedup: duplicate(id) returns false once, true after', () => {
const store = createSessionStore()
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')
// the streamed assistant text now lives in an ordered text part
expect(store.state.messages[2]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live!' })
})
})
describe('session store — ordered parts (Phase 2b)', () => {
test('interleaves text → tool → text as ordered parts in one assistant turn', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'before ' } })
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal' } })
// result_text is the {output,exit_code} JSON envelope — the store strips it.
store.apply({
type: 'tool.complete',
payload: { tool_id: 't1', result_text: '{"output":"hello\\nworld","exit_code":0}' }
})
store.apply({ type: 'message.delta', payload: { text: 'after' } })
store.apply({ type: 'message.complete' })
const msg = store.state.messages.at(-1)!
expect(msg.role).toBe('assistant')
expect(msg.streaming).toBe(false)
const parts = msg.parts ?? []
expect(parts.map(p => p.type)).toEqual(['text', 'tool', 'text'])
expect(parts[0]).toMatchObject({ type: 'text', text: 'before ' })
expect(parts[2]).toMatchObject({ type: 'text', text: 'after' })
const tool = parts[1]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.name).toBe('terminal')
expect(tool.resultText).toBe('hello\nworld') // envelope stripped
expect(tool.lineCount).toBe(2)
} else {
throw new Error('expected a tool part at index 1')
}
})
test('message.complete with text but NO prior start creates the turn (complete-only gateway; no drop)', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
// no message.start / no deltas — straight to complete with the full text
store.apply({ type: 'message.complete', payload: { text: 'The whole answer.' } })
const msg = store.state.messages.at(-1)!
expect(msg.role).toBe('assistant')
expect(msg.streaming).toBe(false)
expect(msg.parts?.some(p => p.type === 'text' && p.text === 'The whole answer.')).toBe(true)
})
test('message.complete with no live turn and no text does NOT create an empty bubble', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.complete', payload: {} })
expect(store.state.messages.filter(m => m.role === 'assistant')).toHaveLength(0)
})
test('tool.complete updates the running tool part IN PLACE (not a new row)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'x', name: 'read_file' } })
expect(store.state.messages.at(-1)!.parts).toHaveLength(1)
expect(store.state.messages.at(-1)!.parts![0]).toMatchObject({ type: 'tool', state: 'running', name: 'read_file' })
store.apply({ type: 'tool.complete', payload: { tool_id: 'x', summary: 'read 42 lines' } })
const parts = store.state.messages.at(-1)!.parts!
expect(parts).toHaveLength(1) // updated in place — NOT appended as a separate row
const tool = parts[0]!
if (tool.type === 'tool') {
expect(tool.state).toBe('complete')
expect(tool.summary).toBe('read 42 lines')
} else {
throw new Error('expected a tool part')
}
})
test('captures tool args: context→argsPreview, args→argsText, duration, omitted note (item 2)', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'terminal', context: 'ls -la src' } })
store.apply({
type: 'tool.complete',
payload: {
tool_id: 'a',
name: 'terminal',
args: { command: 'ls -la src' },
duration_s: 0.34,
result_text: '[showing verbose tail; omitted 3 lines / 90 chars]\nfile1\nfile2'
}
})
const tool = store.state.messages.at(-1)!.parts![0]!
if (tool.type !== 'tool') throw new Error('expected a tool part')
expect(tool.argsPreview).toBe('ls -la src') // primary-arg preview shown in the header (NOT overwritten)
expect(tool.argsText).toContain('"command"') // full args JSON for the expanded view
expect(tool.duration).toBe(0.34)
expect(tool.omittedNote).toBe('3 lines / 90 chars') // tidy note; raw label stripped
expect(tool.resultText).toBe('file1\nfile2') // clean body (label peeled)
expect(tool.lineCount).toBe(2)
})
test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => {
const store = createSessionStore()
store.setCatalog({
tools: {
total: 42,
toolsets: [
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b', 3] },
{ name: 'off', count: 5, enabled: false, tools: [] },
{ name: '', count: 1 }
]
},
skills: { total: 7, categories: [{ name: 'dev', count: 7 }] },
mcp: { servers: ['railway', 123, 'beeper'] },
junk: 'ignored'
})
const c = store.state.catalog!
expect(c.tools.total).toBe(42)
expect(c.tools.toolsets).toEqual([
{ name: 'core', count: 12, enabled: true, tools: ['a', 'b'] }, // non-string tool dropped
{ name: 'off', count: 5, enabled: false, tools: [] } // enabled flag preserved
]) // nameless entry dropped
expect(c.skills.total).toBe(7)
expect(c.mcp.servers).toEqual(['railway', 'beeper']) // non-string dropped
})
test('setCatalog leaves the catalog unset on garbage / non-object input (decode → none)', () => {
const store = createSessionStore()
expect(store.state.catalog).toBeUndefined()
store.setCatalog('not an object')
expect(store.state.catalog).toBeUndefined()
store.setCatalog(null)
expect(store.state.catalog).toBeUndefined()
store.setCatalog(42)
expect(store.state.catalog).toBeUndefined()
})
test('setCatalog accepts a sparse but well-shaped catalog (absent sections default empty)', () => {
const store = createSessionStore()
store.setCatalog({ tools: { total: 3, toolsets: [{ name: 'core', count: 3, tools: ['a'] }] } })
const c = store.state.catalog!
expect(c.tools.total).toBe(3)
expect(c.tools.toolsets).toEqual([{ name: 'core', count: 3, enabled: true, tools: ['a'] }]) // enabled defaults on
expect(c.skills).toEqual({ total: 0, categories: [] }) // absent section → empty
expect(c.mcp.servers).toEqual([])
})
test('reasoning.delta accumulates into a reasoning part', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'reasoning.delta', payload: { text: 'thinking ' } })
store.apply({ type: 'reasoning.delta', payload: { text: 'hard' } })
const parts = store.state.messages.at(-1)!.parts ?? []
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
})
test('thinking.delta (kaomoji face) → transient status, NOT a transcript part; complete clears it', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
store.apply({ type: 'thinking.delta', payload: { text: '(´・_・`) formulating...' } })
expect(store.state.status).toBe('(´・_・`) formulating...')
expect(store.state.messages.at(-1)!.parts ?? []).toHaveLength(0) // no reasoning row from the face
store.apply({ type: 'message.delta', payload: { text: 'Hi!' } })
store.apply({ type: 'message.complete' })
expect(store.state.status).toBeUndefined() // cleared when the turn ends
// only the real reply text part remains — the face never entered the transcript
expect((store.state.messages.at(-1)!.parts ?? []).map(p => p.type)).toEqual(['text'])
})
test('status.update also drives the transient status line', () => {
const store = createSessionStore()
store.apply({ type: 'status.update', payload: { kind: 'tool', text: 'running terminal…' } })
expect(store.state.status).toBe('running terminal…')
})
})
describe('session store — blocking prompts (Phase 3)', () => {
test('approval.request sets an approval prompt; clearPrompt clears it', () => {
const store = createSessionStore()
expect(store.state.prompt).toBeUndefined()
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'delete temp' } })
expect(store.state.prompt).toMatchObject({ kind: 'approval', command: 'rm -rf /tmp/x', description: 'delete temp' })
store.clearPrompt()
expect(store.state.prompt).toBeUndefined()
})
test('clarify.request carries question + choices + request_id', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Which?', choices: ['a', 'b'], request_id: 'r1' } })
const p = store.state.prompt
expect(p).toMatchObject({ kind: 'clarify', question: 'Which?', requestId: 'r1' })
if (p?.kind === 'clarify') expect(p.choices).toEqual(['a', 'b'])
})
test('clarify.request with null choices → free-text only', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Name?', choices: null, request_id: 'r2' } })
const p = store.state.prompt
if (p?.kind === 'clarify') expect(p.choices).toBeNull()
})
test('sudo.request + secret.request set masked prompts', () => {
const store = createSessionStore()
store.apply({ type: 'sudo.request', payload: { request_id: 's1' } })
expect(store.state.prompt).toMatchObject({ kind: 'sudo', requestId: 's1' })
store.apply({ type: 'secret.request', payload: { env_var: 'API_KEY', prompt: 'Enter key', request_id: 's2' } })
expect(store.state.prompt).toMatchObject({ kind: 'secret', envVar: 'API_KEY', requestId: 's2' })
})
})
describe('session store — subagents (Phase 5e agents dashboard)', () => {
test('subagent.* events build + update a subagent by id', () => {
const store = createSessionStore()
store.apply({
type: 'subagent.start',
payload: { subagent_id: 'a1', goal: 'research X', model: 'haiku', depth: 1 }
})
expect(store.state.subagents).toHaveLength(1)
expect(store.state.subagents[0]).toMatchObject({ id: 'a1', goal: 'research X', status: 'running', depth: 1 })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search' } })
expect(store.state.subagents[0]).toMatchObject({ status: 'tool', lastTool: 'web_search' })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'found it' } })
expect(store.state.subagents).toHaveLength(1) // updated in place
expect(store.state.subagents[0]).toMatchObject({ status: 'complete', summary: 'found it' })
})
test('accumulates a live trace per subagent (item 15) + transient thought', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'crunch data' } })
store.apply({ type: 'subagent.thinking', payload: { subagent_id: 'a1', text: 'considering options' } })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
store.apply({ type: 'subagent.progress', payload: { subagent_id: 'a1', text: 'found 3 hits' } })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'done crunching' } })
const sa = store.state.subagents[0]!
// thinking text is transient (not in the trace), the rest is a concise log
expect(sa.thought).toBe('considering options')
expect(sa.trace).toEqual(['▶ crunch data', '⚡ web_search — opentui', 'found 3 hits', '✓ done crunching'])
})
test('clearTranscript also clears subagents', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'g' } })
store.clearTranscript()
expect(store.state.subagents).toHaveLength(0)
})
})
describe('session store — session chrome / status bar (item 14)', () => {
test('session.info populates model/effort/cwd/branch and nested usage context', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: {
model: 'anthropic/claude-opus-4-8',
reasoning_effort: 'high',
fast: true,
cwd: '/home/x/proj',
branch: 'main',
running: false,
usage: { context_used: 42000, context_max: 200000, context_percent: 21 }
}
})
const info = store.state.info
expect(info.model).toBe('anthropic/claude-opus-4-8')
expect(info.effort).toBe('high')
expect(info.fast).toBe(true)
expect(info.cwd).toBe('/home/x/proj')
expect(info.branch).toBe('main')
expect(info.contextPercent).toBe(21)
expect(info.contextMax).toBe(200000)
})
test('session.info reads context from TOP-LEVEL fields when there is no nested usage', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: { model: 'gpt-5.4', context_used: 1000, context_max: 8000, context_percent: 13, compressions: 2 }
})
const info = store.state.info
expect(info.model).toBe('gpt-5.4')
expect(info.contextUsed).toBe(1000)
expect(info.contextMax).toBe(8000)
expect(info.contextPercent).toBe(13)
expect(info.compressions).toBe(2)
})
test('session.info prefers nested usage.context_* over the top-level fallback', () => {
const store = createSessionStore()
store.apply({
type: 'session.info',
payload: { context_percent: 5, usage: { context_percent: 88 } }
})
expect(store.state.info.contextPercent).toBe(88) // nested wins
})
test('session.info with a malformed payload does NOT crash and leaves chrome untouched (decode → none)', () => {
const store = createSessionStore()
store.applyInfo({ model: 'opus', cwd: '/p' })
// a wrong-typed field (model: number) fails the schema → empty patch, prior info survives
store.apply({ type: 'session.info', payload: { model: 123, usage: 'nope' } })
expect(store.state.info).toMatchObject({ model: 'opus', cwd: '/p' })
})
test('session.info with a partial payload only patches the present fields', () => {
const store = createSessionStore()
store.applyInfo({ model: 'opus', branch: 'main', running: true })
store.apply({ type: 'session.info', payload: { branch: 'dev' } }) // only branch present
expect(store.state.info).toMatchObject({ model: 'opus', branch: 'dev', running: true })
})
test('message.start sets running, message.complete clears it + refreshes usage', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
expect(store.state.info.running).toBe(true)
store.apply({ type: 'message.delta', payload: { text: 'hi' } })
store.apply({ type: 'message.complete', payload: { usage: { context_percent: 33 } } })
expect(store.state.info.running).toBe(false)
expect(store.state.info.contextPercent).toBe(33)
})
test('applyInfo merges a session.create info patch without clobbering prior fields', () => {
const store = createSessionStore()
store.applyInfo({ model: 'gpt-5.4', cwd: '/tmp' })
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' })
})
test('setHint sets/clears the transient composer hint (Ctrl+C again to quit — item 11)', () => {
const store = createSessionStore()
expect(store.state.hint).toBeUndefined()
store.setHint('Ctrl+C again to quit')
expect(store.state.hint).toBe('Ctrl+C again to quit')
store.setHint(undefined)
expect(store.state.hint).toBeUndefined()
})
})
describe('session store — gateway lifecycle / transport errors (auto-heal foundations)', () => {
test('gateway.exited clears the frozen running spinner AND pushes a system notice', () => {
const store = createSessionStore()
store.apply({ type: 'message.start' })
expect(store.state.info.running).toBe(true) // a turn is in flight
store.apply({ type: 'gateway.exited' })
// THE key bug fix: the spinner is cleared even though no message.complete arrived.
expect(store.state.info.running).toBe(false)
// Neutral status — "recovering…" now comes from gateway.recovering only.
expect(store.state.status).toBe('gateway exited')
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys).toHaveLength(1)
expect(sys[0]!.text).toContain('in-flight reply was lost')
})
test('gateway.exited enriches the notice with payload.reason when present', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.exited', payload: { reason: 'SIGKILL', code: 137 } })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys[0]!.text).toContain('SIGKILL')
})
test('gateway.recovering reflects the attempt number in the status', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.recovering', payload: { attempt: 2 } })
expect(store.state.status).toBe('gateway recovering (attempt 2)…')
})
test('gateway.stderr is collected (NOT pushed to transcript), surfaced on start_timeout', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.stderr', payload: { line: 'ModuleNotFoundError: no module foo' } })
store.apply({ type: 'gateway.stderr', payload: { line: 'traceback line 2' } })
// chatty stderr never floods the transcript on its own
expect(store.state.messages).toHaveLength(0)
// …but the tail is surfaced when the gateway fails to start
store.apply({ type: 'gateway.start_timeout', payload: {} })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys).toHaveLength(1)
expect(sys[0]!.text).toContain('gateway failed to start')
expect(sys[0]!.text).toContain('ModuleNotFoundError')
})
test('gateway.protocol_error and error are surfaced to the transcript', () => {
const store = createSessionStore()
store.apply({ type: 'gateway.protocol_error', payload: { preview: '<garbled>' } })
store.apply({ type: 'error', payload: { message: 'boom' } })
const sys = store.state.messages.filter(m => m.role === 'system')
expect(sys.map(m => m.text)).toEqual(['gateway protocol error: <garbled>', 'error: boom'])
})
})
describe('session store — resume hydrate (Phase 4b)', () => {
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
const store = createSessionStore()
store.beginBuffer()
// a live event arrives DURING the (async) session.resume RPC
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'live during resume' } })
// the snapshot commits afterwards
store.commitSnapshot([{ role: 'user', text: 'old question' }])
expect(store.state.messages).toHaveLength(2) // snapshot(1) + the replayed assistant turn(1)
expect(store.state.messages[0]).toMatchObject({ role: 'user', text: 'old question' })
expect(store.state.messages[1]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live during resume' })
})
})
describe('session store — rolling message cap (bounds the Yoga node high-water mark)', () => {
const ENV_KEY = 'HERMES_TUI_MAX_MESSAGES'
const prev = process.env[ENV_KEY]
afterEach(() => {
if (prev === undefined) delete process.env[ENV_KEY]
else process.env[ENV_KEY] = prev
})
test('caps the message array at the env-tuned MESSAGE_CAP, dropping the oldest (head)', () => {
process.env[ENV_KEY] = '5'
const store = createSessionStore()
// push more than the cap; each distinct so we can tell which survived
for (let i = 0; i < 55; i++) store.pushUser(`msg ${i}`)
expect(store.state.messages).toHaveLength(5)
expect(store.state.dropped).toBe(50) // head-sliced overflow is counted for the notice
// the oldest 50 were sliced from the head; survivors are the last 5 (msg 50..54)
expect(store.state.messages[0]!.text).toBe('msg 50')
expect(store.state.messages.at(-1)!.text).toBe('msg 54')
})
test('pushSystem is also capped (head-dropped) at MESSAGE_CAP', () => {
process.env[ENV_KEY] = '3'
const store = createSessionStore()
for (let i = 0; i < 10; i++) store.pushSystem(`sys ${i}`)
expect(store.state.messages).toHaveLength(3)
expect(store.state.messages[0]!.text).toBe('sys 7')
expect(store.state.messages.at(-1)!.text).toBe('sys 9')
})
test('the in-flight streaming turn it opens at overflow SURVIVES the cap (head sliced, not tail)', () => {
process.env[ENV_KEY] = '4'
const store = createSessionStore()
// fill to the cap with user rows so the next push overflows
store.pushUser('u0')
store.pushUser('u1')
store.pushUser('u2')
store.pushUser('u3') // array now at the cap (4): [u0, u1, u2, u3]
expect(store.state.messages).toHaveLength(4)
// message.start pushes the assistant turn as the LAST row (length 5) → head sliced to 4.
// The freshly-pushed streaming turn is the tail, so it must NOT be the one evicted.
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'in flight' } })
expect(store.state.messages).toHaveLength(4)
expect(store.state.messages[0]!.text).toBe('u1') // 'u0' dropped from the head, not the tail turn
const live = store.state.messages.at(-1)!
expect(live.role).toBe('assistant')
expect(live.streaming).toBe(true)
expect(live.parts?.[0]).toMatchObject({ type: 'text', text: 'in flight' })
})
test('message.start is capped: opening a turn beyond the cap drops the oldest', () => {
process.env[ENV_KEY] = '2'
const store = createSessionStore()
store.pushUser('a')
store.pushUser('b')
store.apply({ type: 'message.start' }) // array would be 3 → trimmed to 2
expect(store.state.messages).toHaveLength(2)
expect(store.state.messages[0]!.text).toBe('b') // 'a' dropped from the head
expect(store.state.messages.at(-1)!.role).toBe('assistant')
})
test('commitSnapshot caps an over-cap resume snapshot (oldest history dropped)', () => {
process.env[ENV_KEY] = '3'
const store = createSessionStore()
const snapshot: Message[] = Array.from({ length: 8 }, (_, i) => ({ role: 'user', text: `h${i}` }))
store.beginBuffer()
store.commitSnapshot(snapshot)
expect(store.state.messages).toHaveLength(3)
expect(store.state.dropped).toBe(5) // 8 snapshot 3 kept; resume SETS the count
expect(store.state.messages[0]!.text).toBe('h5')
expect(store.state.messages.at(-1)!.text).toBe('h7')
})
test('defaults to 3000 when the env var is unset/invalid', () => {
delete process.env[ENV_KEY]
const store = createSessionStore()
for (let i = 0; i < 3050; i++) store.pushUser(`m${i}`)
expect(store.state.messages).toHaveLength(3000)
expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped
})
test('clearTranscript empties messages AND the applied dedup set', () => {
const store = createSessionStore()
store.pushUser('x')
// seed the dedup set with an id, then confirm it is now treated as seen
expect(store.duplicate('seen-1')).toBe(false)
expect(store.duplicate('seen-1')).toBe(true)
store.clearTranscript()
expect(store.state.messages).toHaveLength(0)
// after clear the previously-seen id is processed again (the applied Set was cleared)
expect(store.duplicate('seen-1')).toBe(false)
})
test('clearTranscript resets the dropped counter (the truncation notice clears)', () => {
process.env[ENV_KEY] = '2'
const store = createSessionStore()
for (let i = 0; i < 5; i++) store.pushUser(`m${i}`) // 5 pushed, cap 2 → 3 dropped
expect(store.state.dropped).toBe(3)
store.clearTranscript()
expect(store.state.dropped).toBe(0)
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* toolOutput unit test (spec v4 §5 Layer 4 — Hermes-specific contract). The
* `{output,exit_code}` envelope unwrap + the line/char collapse, as pure data.
*/
import { describe, expect, test } from 'vitest'
import { collapseToolOutput, stripAnsi, stripOmittedNote, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
describe('stripAnsi (item 8 - gateway slash/notice text is ANSI-colored for Ink)', () => {
const ESC = String.fromCharCode(27)
test('removes SGR color codes, keeps the text', () => {
expect(stripAnsi(`${ESC}[1;38;2;255;215;0m\u2713 Reasoning display: ON${ESC}[0m`)).toBe(
'\u2713 Reasoning display: ON'
)
})
test('removes italic + mouse sequences', () => {
expect(stripAnsi(`${ESC}[2;3m Model thinking shown.${ESC}[0m`)).toBe(' Model thinking shown.')
expect(stripAnsi(`hi${ESC}[<0;6;8mthere`)).toBe('hithere')
})
test('leaves plain text untouched', () => {
expect(stripAnsi('just text')).toBe('just text')
})
})
describe('stripOmittedNote (item 2 — peel the gateway verbose-tail label)', () => {
test('extracts the lines/chars note and returns the clean body', () => {
const { body, omittedNote } = stripOmittedNote(
'[showing verbose tail; omitted 5 lines / 234 chars]\nline one\nline two'
)
expect(omittedNote).toBe('5 lines / 234 chars')
expect(body).toBe('line one\nline two')
})
test('extracts a chars-only note', () => {
const { body, omittedNote } = stripOmittedNote('[showing verbose tail; omitted 512 chars]\ntail body')
expect(omittedNote).toBe('512 chars')
expect(body).toBe('tail body')
})
test('passes through unlabeled output untouched', () => {
const { body, omittedNote } = stripOmittedNote('normal output\nno prefix')
expect(omittedNote).toBeUndefined()
expect(body).toBe('normal output\nno prefix')
})
})
describe('stripToolEnvelope', () => {
test('unwraps {output,exit_code} → output', () => {
expect(stripToolEnvelope('{"output":"hi","exit_code":0}')).toBe('hi')
})
test('appends an [exit N] suffix on non-zero exit', () => {
expect(stripToolEnvelope('{"output":"oops","exit_code":2}')).toBe('oops\n[exit 2]')
})
test('appends an [error] suffix when error is set', () => {
expect(stripToolEnvelope('{"output":"x","error":"boom"}')).toBe('x\n[error] boom')
})
test('passes through non-JSON / non-envelope unchanged', () => {
expect(stripToolEnvelope('just text')).toBe('just text')
expect(stripToolEnvelope('{not json')).toBe('{not json')
expect(stripToolEnvelope('{"result":"no output key"}')).toBe('{"result":"no output key"}')
})
test('unwraps a TAIL-capped envelope fragment (item 2 — gateway serialises then tail-caps)', () => {
// head was cut, tail keeps the envelope close → strip the trailing close
expect(stripToolEnvelope('zsh\nzutty", "exit_code": 0, "error": null}')).toBe('zsh\nzutty')
// head survived, tail cut → strip the leading {"output": "
expect(stripToolEnvelope('{"output": "line1\nline2')).toBe('line1\nline2')
// real output that merely mentions exit_code is NOT mangled
expect(stripToolEnvelope('the exit_code was 0 here')).toBe('the exit_code was 0 here')
})
test('un-double-escapes literal \\n when they dominate (item 7 verbose tail)', () => {
// double-escaped output (literal backslash-n) → real newlines
expect(stripToolEnvelope('a\\nb\\nc')).toBe('a\nb\nc')
// genuine multi-line output (real newlines) with one literal \n is left alone
expect(stripToolEnvelope('line1\nline2\nshow \\n here')).toBe('line1\nline2\nshow \\n here')
})
})
describe('collapseToolOutput / truncate', () => {
test('caps to maxLines and reports the hidden count', () => {
const c = collapseToolOutput('a\nb\nc\nd', 2, 10)
expect(c.lines).toEqual(['a', 'b'])
expect(c.hiddenLines).toBe(2)
expect(c.truncated).toBe(true)
})
test('no truncation when within the cap', () => {
const c = collapseToolOutput('a\nb', 5, 10)
expect(c.lines).toEqual(['a', 'b'])
expect(c.hiddenLines).toBe(0)
expect(c.truncated).toBe(false)
})
test('truncate adds an ellipsis only when cut', () => {
expect(truncate('abcdef', 4)).toBe('abc…')
expect(truncate('ab', 4)).toBe('ab')
})
})
+143
View File
@@ -0,0 +1,143 @@
/**
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
* that is either the PAGER overlay (long slash output) or the normal
* transcript + input zone; the input zone is one of: blocking prompt, session
* switcher, generic picker (model/skills), or the composer. Fully themed (§7.5).
*
* header flexShrink:0 (top chrome line)
* content flexGrow:1, minHeight:0 — Pager OR (transcript + input zone)
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
* input zone flexShrink:0 (PromptOverlay | SessionSwitcher | Picker | Composer)
*
* Overlays REPLACE rather than stack (a `<Switch>`), so the composer remounts +
* refocuses when an overlay closes; the key that closed an overlay can't leak
* into it because the close is deferred a tick.
*/
import { Match, Switch } from 'solid-js'
import { deferClose } from '../logic/defer.ts'
import type { PromptHistory } from '../logic/history.ts'
import type { PasteStore } from '../logic/pastes.ts'
import type { SessionStore } from '../logic/store.ts'
import { Composer } from './composer.tsx'
import { DimensionsProvider } from './dimensions.tsx'
import { Header } from './header.tsx'
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
import { Pager } from './overlays/pager.tsx'
import { Picker } from './overlays/picker.tsx'
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { StatusBar } from './statusBar.tsx'
import { StatusLine } from './statusLine.tsx'
import { useTheme } from './theme.tsx'
import { Transcript } from './transcript.tsx'
export interface AppProps {
readonly store: SessionStore
readonly onSubmit?: (text: string) => void
readonly onType?: (text: string) => void
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
readonly onResume?: (sessionId: string) => void
readonly sessionId?: () => string | undefined
readonly history?: PromptHistory
readonly onImagePaste?: () => void
readonly pasteStore?: PasteStore
}
const NOOP = () => {}
const NOOP_RESPOND = () => {}
const NOOP_RESUME = () => {}
const NO_SESSION = () => undefined
export function App(props: AppProps) {
const theme = useTheme()
const blocked = () => props.store.state.prompt !== undefined
const pager = () => props.store.state.pager
const dashboard = () => props.store.state.dashboard
const switcher = () => props.store.state.switcher
const picker = () => props.store.state.picker
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
// the freshly-remounted composer (see deferClose).
const closePager = () => deferClose(() => props.store.closePager())
const closeDashboard = () => deferClose(() => props.store.closeDashboard())
const closeSwitcher = () => deferClose(() => props.store.closeSwitcher())
const closePicker = () => deferClose(() => props.store.closePicker())
const resume = (id: string) => {
;(props.onResume ?? NOOP_RESUME)(id)
closeSwitcher()
}
return (
<DimensionsProvider>
<box style={{ flexDirection: 'column', flexGrow: 1, paddingTop: 1, paddingLeft: 1, paddingRight: 1 }}>
{/* a bottom rule under the header bookends the transcript with the status
bar's top rule — frames the chrome as intentional (item 8). */}
<box border={['bottom']} borderColor={theme().color.border} style={{ flexShrink: 0 }}>
<Header store={props.store} />
</box>
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
<Switch
fallback={
<>
<Transcript store={props.store} />
{/* transient busy face floats at the bottom of the transcript area */}
<StatusLine store={props.store} />
{/* input region — a top-edge rule separates the status bar + textbox from the
transcript above; the status bar sits directly ABOVE the composer (item 14). */}
<box
border={['top']}
borderColor={theme().color.border}
style={{ flexShrink: 0, flexDirection: 'column' }}
>
<StatusBar store={props.store} />
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
completionFrom={() => props.store.state.completionFrom}
onDismiss={() => props.store.clearCompletions()}
history={props.history}
onImagePaste={props.onImagePaste}
pasteStore={props.pasteStore}
/>
}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
/>
)}
</Match>
</Switch>
</box>
</>
}
>
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
<Match when={dashboard()}>
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
</Match>
</Switch>
</box>
</DimensionsProvider>
)
}
+240
View File
@@ -0,0 +1,240 @@
/**
* Composer — the input row (spec v4 §2). A native <textarea> captured by ref;
* Enter submits, the input clears imperatively, and a live slash-completion
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
*
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
*
* Completions: `onContentChange` reports the text → `onType` (entry boundary)
* queries `complete.slash` and fills `completions()`. The textarea owns key input
* (so live-refine-by-typing works), so we use Tab to accept the top match and Esc
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
*
* Always-active input (item 2): the textarea focuses on mount, on click
* (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever
* drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav
* keys are left alone so keyboard transcript-scroll still works (opencode keeps
* the prompt focused via a reactive effect; here a keystroke net is enough since
* the composer remounts+refocuses whenever an overlay closes).
*/
import { type PasteEvent, type TextareaRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, onMount, Show } from 'solid-js'
import type { CompletionItem } from '../logic/store.ts'
import type { PromptHistory } from '../logic/history.ts'
import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */
const NAV_KEYS = new Set([
'return',
'linefeed',
'tab',
'escape',
'backspace',
'delete',
'insert',
'up',
'down',
'left',
'right',
'home',
'end',
'pageup',
'pagedown',
'clear',
'menu'
])
/** A printable, unmodified key press (recoverable into the textarea). */
function isPrintableKey(k: {
name: string
ctrl: boolean
meta: boolean
option: boolean
super?: boolean
sequence: string
eventType?: string
}): boolean {
return (
k.eventType !== 'release' &&
!k.ctrl &&
!k.meta &&
!k.option &&
!k.super &&
!NAV_KEYS.has(k.name) &&
typeof k.sequence === 'string' &&
k.sequence.length >= 1 &&
(k.sequence.codePointAt(0) ?? 0) >= 0x20
)
}
export function Composer(props: {
onSubmit: (text: string) => void
onType?: ((text: string) => void) | undefined
completions?: (() => CompletionItem[]) | undefined
completionFrom?: (() => number) | undefined
onDismiss?: (() => void) | undefined
history?: PromptHistory | undefined
onImagePaste?: (() => void) | undefined
pasteStore?: PasteStore | undefined
}) {
const theme = useTheme()
const dims = useDimensions()
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
let ta: TextareaRenderable | undefined
let submitting = false
const completions = () => props.completions?.() ?? []
/** Replace the textarea content and park the cursor at the end (history recall). */
const setBuffer = (text: string) => {
if (!ta) return
ta.setText(text)
ta.cursorOffset = text.length
}
const submit = () => {
if (submitting || !ta) return
// Expand any `[Pasted text #N]` placeholders back to their full content before
// sending (item: pasted-text). No-op when nothing was placeheld.
const text = (props.pasteStore?.expand(ta.plainText) ?? ta.plainText).trim()
if (!text) return
submitting = true
props.onSubmit(text)
props.history?.push(text)
ta.clear()
props.pasteStore?.clear()
props.onDismiss?.()
submitting = false
}
useKeyboard(key => {
// 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open
if (completions().length > 0) {
if (key.name === 'tab') {
const top = completions()[0]
if (top && ta) {
// splice only the token being completed (slash-arg / @-mention), not the
// whole line — `completionFrom` is the gateway's replace_from / token start.
const from = props.completionFrom?.() ?? 0
const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length))
setBuffer(before + top.text + ' ')
props.onDismiss?.()
}
return
}
if (key.name === 'escape') {
props.onDismiss?.()
return
}
}
// 2) prompt history (item 6): Up at the first line → older prompt; Down at the
// last line → newer/draft. At the boundary the textarea's own up/down is a
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
if (ta && props.history) {
if (key.name === 'up' && ta.logicalCursor.row === 0) {
const entry = props.history.prev(ta.plainText)
if (entry !== null) setBuffer(entry)
return
}
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
const entry = props.history.next()
if (entry !== null) setBuffer(entry)
return
}
// any edit resets the recall cursor so the next Up starts from the bottom
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
props.history.reset()
}
}
// 3) always-active input (item 2): a printable key while the textarea lost
// focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the
// key to the focused renderable, so after focus() the SAME keystroke is still
// delivered to the (now-focused) textarea — do NOT insert it here too, or the
// first letter doubles. Nav/scroll keys are untouched.
if (ta && !ta.focused && isPrintableKey(key)) {
ta.focus()
}
})
onMount(() => ta?.focus())
return (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Show when={completions().length > 0}>
<box
style={{
backgroundColor: theme().color.completionBg,
flexDirection: 'column',
paddingLeft: 1,
paddingRight: 1
}}
>
{/* the completion dropdown is transient input chrome (menu rows + the
key-hint) — not transcript content — so it's excluded from mouse
selection (item 4). */}
<For each={completions().slice(0, 8)}>
{(c, i) => (
<text selectable={false} fg={i() === 0 ? theme().color.accent : theme().color.text}>
{c.display || c.text}
{c.meta ? ` ${c.meta}` : ''}
</text>
)}
</For>
<text selectable={false} fg={theme().color.muted}>
Tab complete · Esc dismiss
</text>
</box>
</Show>
{/* prompt glyph + textarea — the glyph (item 3) marks the input line so the
composer is distinguished by structure (glyph + the status-bar rule above),
not a background tint. */}
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
</text>
</box>
<textarea
ref={el => (ta = el)}
minHeight={1}
maxHeight={maxHeight()}
style={{ flexGrow: 1, minWidth: 0 }}
placeholder={theme().brand.welcome}
placeholderColor={theme().color.muted}
textColor={theme().color.text}
cursorColor={theme().color.accent}
keyBindings={[{ action: 'submit', name: 'return' }]}
onMouseDown={() => ta?.focus()}
onSubmit={submit}
onPaste={(e: PasteEvent) => {
const text = new TextDecoder().decode(e.bytes)
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
if (text.trim() === '') {
e.preventDefault()
props.onImagePaste?.()
return
}
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
// of flooding the input; the real text is expanded back on submit.
if (props.pasteStore && shouldPlaceholder(text)) {
e.preventDefault()
ta?.insertText(props.pasteStore.add(text))
return
}
// small pastes fall through to the textarea's native insert
}}
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
/>
</box>
</box>
)
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Shared, COALESCED terminal dimensions (item 4 — resize hardening). Raw
* `useTerminalDimensions()` fires on every SIGWINCH tick; during a drag that's a
* recompute/reflow storm across every width-sensitive component (tool bodies,
* tables, status bar, banner). One provider runs the raw hook once and feeds a
* single leading+trailing-debounced signal (opencode's createLeadingTrailingSignal
* idiom, mirroring the gateway's 16ms event coalescing) that every consumer shares
* — so they reflow together (no tearing) and at most once per COALESCE window.
*/
import { useTerminalDimensions } from '@opentui/solid'
import { type Accessor, createContext, createEffect, createSignal, type JSX, onCleanup, useContext } from 'solid-js'
export interface Dims {
readonly width: number
readonly height: number
}
const DimsContext = createContext<Accessor<Dims>>()
const COALESCE_MS = 40
export function DimensionsProvider(props: { children: JSX.Element }) {
const raw = useTerminalDimensions()
const [dims, setDims] = createSignal<Dims>({ height: raw().height, width: raw().width })
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next: Dims = { height: raw().height, width: raw().width } // track raw
const now = Date.now()
if (now - last >= COALESCE_MS) {
last = now
setDims(next) // leading edge: respond immediately to the first change
} else {
// trailing edge: coalesce the burst, land on the final size once it settles
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
last = Date.now()
setDims(next)
}, COALESCE_MS)
}
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return <DimsContext.Provider value={dims}>{props.children}</DimsContext.Provider>
}
/** Coalesced dimensions; falls back to the raw hook outside a provider (e.g. headless tests). */
export function useDimensions(): Accessor<Dims> {
return useContext(DimsContext) ?? useTerminalDimensions()
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Header — the top chrome line (spec v4 §2 `view/header.tsx`). Phase 2 skeleton:
* brand · engine · ready/connecting, fully themed (`useTheme()`, NO hardcoded
* styles — §7.5). Model / cwd / context% / cost land in Phase 5b once
* `session.info` + `Usage` are wired.
*/
import { Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function Header(props: { store: SessionStore }) {
const theme = useTheme()
return (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
{/* brand glyph in accent + name in primary/bold so the header reads as the
top of the hierarchy, not just another text line (item 8). */}
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
<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>
)
}
+203
View File
@@ -0,0 +1,203 @@
/**
* HomeHint — the empty-transcript home screen (items 12 + 9; Ink `branding.tsx`
* parity). The HERMES-AGENT banner + a tagline, then a session info block
* (model · Nous Research / dir / Session id), then SEPARATE collapsible sections —
* Available Tools (enabled toolsets + their tools), Available Skills, MCP Servers —
* and a summary line. Fully themed; decorative, so `selectable={false}` (item 4).
*/
import { createSignal, For, type JSX, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { truncate } from '../logic/toolOutput.ts'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
// The canonical HERMES-AGENT block logo (hermes_cli/banner.py), gold→amber→bronze.
const BANNER: ReadonlyArray<readonly [string, 'primary' | 'accent' | 'border']> = [
['██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', 'primary'],
['██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', 'primary'],
['███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║', 'accent'],
['██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║', 'accent'],
['██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║', 'border'],
['╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝', 'border']
]
const BANNER_W = 102
const TOOLSETS_MAX = 10
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`. */
const shortModel = (m: string) => (m.includes('/') ? (m.split('/').at(-1) ?? m) : m)
const HOME = process.env.HOME ?? ''
const shortCwd = (cwd: string) => (HOME && cwd.startsWith(HOME) ? '~' + cwd.slice(HOME.length) : cwd)
export function HomeHint(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useDimensions()
const wide = () => dims().width >= BANNER_W
const cat = () => props.store.state.catalog
const info = () => props.store.state.info
const enabledToolsets = () => (cat()?.tools.toolsets ?? []).filter(t => t.enabled)
// A collapsible section: ▸/▾ accent chevron + label title + optional muted suffix.
function Section(p: { title: string; suffix?: string; open?: boolean; children: JSX.Element }) {
const [open, setOpen] = createSignal(p.open ?? false)
return (
<box style={{ flexDirection: 'column', marginTop: 1 }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => setOpen(o => !o)}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{open() ? '▾ ' : '▸ '}</span>
<span style={{ fg: theme().color.label }}>{p.title}</span>
<Show when={p.suffix}>
<span style={{ fg: theme().color.muted }}>{` ${p.suffix}`}</span>
</Show>
</text>
</box>
<Show when={open()}>
<box
style={{ flexDirection: 'column', marginLeft: 2, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
>
{p.children}
</box>
</Show>
</box>
)
}
return (
<box style={{ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, marginTop: 1 }}>
{/* banner — full block logo when there's room, else a compact brand line */}
<Show
when={wide()}
fallback={
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{theme().brand.icon} </span>
<span style={{ fg: theme().color.primary }}>
<b>{theme().brand.name}</b>
</span>
</text>
}
>
<For each={BANNER}>
{([line, tone]) => (
<text selectable={false}>
<span style={{ fg: theme().color[tone] }}>{line}</span>
</text>
)}
</For>
</Show>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`${theme().brand.icon} `}</span>
<span style={{ fg: theme().color.muted }}>Nous Research · Messenger of the Digital Gods</span>
</text>
{/* framed session panel (Ink SessionPanel parity) — the bordered box is the
key "this is a designed home screen, not log output" signal. */}
<box
style={{ flexDirection: 'column', marginTop: 1, paddingLeft: 1, paddingRight: 1 }}
border
borderColor={theme().color.border}
>
{/* session info block: model · Nous Research / dir / Session id */}
<box style={{ flexDirection: 'column' }}>
<Show when={info().model}>
{model => (
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{shortModel(model())}</span>
<span style={{ fg: theme().color.muted }}> · Nous Research</span>
</text>
)}
</Show>
<Show when={info().cwd}>
{cwd => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{shortCwd(cwd())}</span>
<Show when={info().branch}>
<span style={{ fg: theme().color.muted }}>{` (${info().branch})`}</span>
</Show>
</text>
)}
</Show>
<Show when={props.store.state.sessionId}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>Session: </span>
<span style={{ fg: theme().color.border }}>{props.store.state.sessionId}</span>
</text>
</Show>
</box>
{/* SEPARATE collapsible sections (Ink parity) + summary */}
<Show when={cat()}>
{c => (
<box style={{ flexDirection: 'column' }}>
<Section title="Available Tools" open>
<For each={enabledToolsets().slice(0, TOOLSETS_MAX)}>
{ts => (
<text selectable={false}>
<span style={{ fg: theme().color.label }}>{`${ts.name}: `}</span>
<span style={{ fg: theme().color.muted }}>
{truncate(
ts.tools.join(', ') || `${ts.count} tools`,
Math.max(20, dims().width - ts.name.length - 8)
)}
</span>
</text>
)}
</For>
<Show when={enabledToolsets().length > TOOLSETS_MAX}>
<text selectable={false}>
<span
style={{ fg: theme().color.muted }}
>{`(and ${enabledToolsets().length - TOOLSETS_MAX} more toolsets…)`}</span>
</text>
</Show>
</Section>
<Section
title={`Available Skills (${c().skills.total})`}
suffix={`in ${c().skills.categories.length} categories`}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
{c()
.skills.categories.map(s => `${s.name} (${s.count})`)
.join(' ')}
</span>
</text>
</Section>
<Section
title={`MCP Servers (${c().mcp.servers.length})`}
suffix={c().mcp.servers.length ? 'connected' : ''}
>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{c().mcp.servers.join(' ') || 'none configured'}</span>
</text>
</Section>
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.text }}>{`${c().tools.total} tools`}</span>
<span
style={{ fg: theme().color.muted }}
>{` · ${c().skills.total} skills · ${c().mcp.servers.length} MCP · `}</span>
<span style={{ fg: theme().color.accent }}>/help</span>
<span style={{ fg: theme().color.muted }}> for commands</span>
</text>
</box>
</box>
)}
</Show>
</box>
{/* end framed session panel */}
<box style={{ marginTop: 1 }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>
Type to chat · history · @file to mention · Ctrl+C to stop/quit
</span>
</text>
</box>
</box>
)
}
+34
View File
@@ -0,0 +1,34 @@
/**
* keymap.tsx — thin Solid helpers over the native `@opentui/keymap` (Phase 3).
*
* `useCloseLayer` is the shared CLOSE binding for overlays/prompts: a `close`
* command bound to Esc and Ctrl+C, scoped to the overlay's root box via a
* `focus-within` layer (the default when a `target` accessor is present). The
* box itself isn't focused — the native `<select>`/`<textarea>` inside it is —
* so `focus-within` is what makes the layer active while the overlay owns the
* screen. The keymap host is provided once at the entry by `<KeymapProvider>`.
*/
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
/**
* Bind Esc / Ctrl+C → `onClose`, scoped to the given root box (focus-within).
* Until the ref resolves the layer simply isn't registered (useBindings waits).
*/
export function useCloseLayer(target: () => BoxRenderable | undefined, onClose: () => void): void {
useBindings<BoxRenderable>(() => ({
target,
commands: [
{
name: 'close',
run() {
onClose()
}
}
],
bindings: [
{ key: 'escape', cmd: 'close' },
{ key: { name: 'c', ctrl: true }, cmd: 'close' }
]
}))
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Markdown — assistant/reasoning text via the NATIVE `<markdown>` renderable
* (`MarkdownRenderable`), exactly as opencode's TextPart (`routes/session/index.tsx`
* :1687 `<markdown streaming internalBlockMode="top-level" tableOptions conceal>`).
*
* Why `<markdown>` (not `<code filetype="markdown">`): the anti-flicker mechanism
* is `internalBlockMode="top-level"` — each top-level block (heading/para/list/
* table/fence) becomes its own child renderable and `_stableBlockCount` (managed
* internally) reports the settled head prefix, so stable blocks are NOT re-rendered
* per streamed delta. The old `<code>` path re-measured the whole buffer each delta
* → the content height oscillated → the scrollbar grew/shrank (the streaming
* flicker regression). `tableOptions` renders GFM tables as an aligned grid WITH
* inline markdown (bold/italic/code) inside cells — so a separate table renderer
* is unnecessary. `streaming` keeps the trailing block open while chunks append and
* finalizes it (half-open tables/fences) when flipped false.
*
* The `SyntaxStyle` is derived from the active theme (no hardcoded styles — §7.5)
* and cached by theme-object identity, so all text parts share ONE instance and
* it's rebuilt only when the skin changes (a new `Theme` object).
*/
import { RGBA, SyntaxStyle } from '@opentui/core'
import type { Theme } from '../logic/theme.ts'
import { useTheme } from './theme.tsx'
const FALLBACK = RGBA.fromHex('#E6EDF3')
const HEX6 = /^#[0-9a-fA-F]{6}$/
/** Theme colors are usually hex but may be `ansi256(n)`/`rgb(...)` after light-mode
* normalization — only hand hex to RGBA.fromHex, else fall back. */
function rgba(color: string): RGBA {
return HEX6.test(color) ? RGBA.fromHex(color) : FALLBACK
}
function buildSyntaxStyle(theme: Theme): SyntaxStyle {
const c = theme.color
return SyntaxStyle.fromStyles({
default: { fg: rgba(c.text) },
'markup.heading': { bold: true, fg: rgba(c.primary) },
'markup.heading.1': { bold: true, fg: rgba(c.primary) },
'markup.heading.2': { bold: true, fg: rgba(c.accent) },
'markup.heading.3': { bold: true, fg: rgba(c.accent) },
'markup.bold': { bold: true, fg: rgba(c.text) },
'markup.italic': { fg: rgba(c.text), italic: true },
'markup.list': { fg: rgba(c.accent) },
'markup.quote': { fg: rgba(c.muted) },
'markup.link': { fg: rgba(c.accent) },
'markup.raw': { fg: rgba(c.label) },
'markup.raw.block': { fg: rgba(c.label) }
})
}
let cache: { theme: Theme; style: SyntaxStyle } | undefined
function syntaxStyleFor(theme: Theme): SyntaxStyle {
if (cache && cache.theme === theme) return cache.style
const style = buildSyntaxStyle(theme)
cache = { style, theme }
return style
}
export function Markdown(props: { text: string; streaming?: boolean; fg?: string }) {
const theme = useTheme()
// `internalBlockMode="top-level"` is the anti-flicker mode (stable head blocks
// aren't re-rendered per delta); `tableOptions` gives native GFM tables with
// inline formatting; `fg` overrides the base text color (muted for reasoning).
// `conceal` hides the markdown markers for clean prose — mouse-selection then
// copies the RENDERED text (markers gone) via native selection, by design.
return (
<markdown
content={props.text}
syntaxStyle={syntaxStyleFor(theme())}
streaming={props.streaming ?? false}
internalBlockMode="top-level"
tableOptions={{ style: 'grid', borderColor: theme().color.border }}
conceal
fg={props.fg ?? theme().color.text}
/>
)
}
+95
View File
@@ -0,0 +1,95 @@
/**
* MessageLine — renders one transcript row (spec v4 §2 / §7). An assistant turn
* is ONE ordered `parts[]` dispatched by `<Switch>`/`<Match>` on `part.type`, so
* text / reasoning / tool interleave INLINE (the §7 fix for "tools dump below").
* User/system rows (and settled/resumed assistant rows with no parts) render flat
* `text`. Fully themed; rich text via <b>/<span>, never an attributes bitmask (§8 #1).
*
* Stable `id` per part as the <For> key so a new tool part below a streaming text
* part doesn't remount it. Native <markdown> for text parts lands in 2b-ii.
*/
import { For, Match, Show, Switch } from 'solid-js'
import type { Message } from '../logic/store.ts'
import { Markdown } from './markdown.tsx'
import { ReasoningPart } from './reasoningPart.tsx'
import { useTheme } from './theme.tsx'
import { ToolPart } from './toolPart.tsx'
const GUTTER = 2
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
// GOLD (label), the agent's answer is BRIGHT (text), system notes are DIM (muted).
const glyphFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'assistant' ? theme().color.accent : theme().color.muted
const bodyFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
const hasParts = () => (m().parts?.length ?? 0) > 0
return (
// One blank line above every turn so user / assistant / tool blocks read as
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative — exclude it from mouse selection (item 4).
Bold so the user `` / assistant `⚕` turn boundaries pop (item 8). */}
<text selectable={false}>
<span style={{ fg: glyphFg() }}>
<b>{glyph()}</b>
</span>
</text>
</box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
reasoning / tool regardless of order or stream timing, so blank lines
don't pop in and out as parts are created/merged mid-stream. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
// No parts yet: the just-started streaming turn shows ONLY the caret,
// inline with the glyph (not an empty line + a dangling caret below —
// item 10 cursor misalignment); a settled row shows its flat text.
<Show
when={m().streaming && !hasParts()}
fallback={
// themed selection: a solid muted/accent bar that preserves the
// text fg (no selectionFg → the original color shows through, so a
// highlight over content reads as a clean bar, not SGR-inverse).
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: bodyFg() }}>{m().text}</span>
</text>
}
>
<text selectable={false}>
{/* streaming caret — a cursor glyph, not content (item 4) */}
<span style={{ fg: theme().color.muted }}></span>
</text>
</Show>
}
>
<For each={m().parts ?? []}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> fed the growing text in place (no
per-delta remount → no scrollbar flicker, #2); it renders GFM
tables natively (#3). Leading/trailing blanks stripped so the
column `gap` is the sole inter-part spacing (item 5). */}
{t => <Markdown text={t().text.replace(/^\n+|\n+$/g, '')} streaming={m().streaming ?? false} />}
</Match>
</Switch>
)}
</For>
</Show>
</box>
</box>
)
}
@@ -0,0 +1,143 @@
/**
* AgentsDashboard — the delegation/subagents view (spec §2b; Ink `agentsOverlay`,
* item 15 "look into an agent trace live"). Master-detail:
* - top: the subagents tracked from the `subagent.*` stream, indented by depth;
* ↑/↓ SELECT a row (highlighted).
* - bottom: the SELECTED subagent's live trace (goal · status · model, latest
* thought, and the tool/progress/summary log) — sticky-bottom so it follows
* live; PgUp/PgDn scroll it.
* Esc/Ctrl+C close (native keymap). §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
*/
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { createSignal, For, onMount, Show } from 'solid-js'
import type { SubagentInfo } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 8
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
const c = theme().color
if (status === 'complete') return c.ok
if (status === 'tool' || status === 'working') return c.accent
if (status.includes('error') || status === 'failed') return c.error
return c.warn
}
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
const theme = useTheme()
const [sel, setSel] = createSignal(0)
let rootRef: BoxRenderable | undefined
let traceBox: ScrollBoxRenderable | undefined
const count = () => props.subagents.length
const selected = () => Math.min(sel(), Math.max(0, count() - 1))
const current = () => props.subagents[selected()]
// Close (Esc/Ctrl+C) is the native keymap; select + scroll stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is active.
onMount(() => rootRef?.focus())
useCloseLayer(
() => rootRef,
() => props.onClose()
)
useKeyboard(key => {
// `q` closes (footer advertises "Esc/q close"); Esc/Ctrl+C close via the keymap.
if (key.name === 'q') return props.onClose()
if (key.name === 'up') setSel(s => Math.max(0, s - 1))
else if (key.name === 'down') setSel(s => Math.min(Math.max(0, count() - 1), s + 1))
else if (key.name === 'pageup') traceBox?.scrollBy(-PAGE)
else if (key.name === 'pagedown') traceBox?.scrollBy(PAGE)
})
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>
Agents · {count()} subagent{count() === 1 ? '' : 's'}
</b>
</text>
</box>
{/* master: the subagent list (↑/↓ select) */}
<box style={{ flexShrink: 0, flexDirection: 'column', maxHeight: 10 }}>
<Show
when={count() > 0}
fallback={<text fg={theme().color.muted}>No subagents yet delegate a task to spawn one.</text>}
>
<For each={props.subagents}>
{(sa, i) => (
<text onMouseDown={() => setSel(i())}>
<span style={{ fg: theme().color.muted }}>{' '.repeat(Math.max(0, sa.depth))}</span>
<span style={{ fg: i() === selected() ? theme().color.accent : theme().color.muted }}>
{i() === selected() ? '▸ ' : ' '}
</span>
<span style={{ fg: statusColor(sa.status, theme) }}>{`${sa.status}`}</span>
<span style={{ fg: theme().color.label }}>{` ${sa.goal || sa.id}`}</span>
<span style={{ fg: theme().color.muted }}>{sa.lastTool ? `${sa.lastTool}` : ''}</span>
</text>
)}
</For>
</Show>
</box>
{/* detail: the selected subagent's live trace */}
<box style={{ flexGrow: 1, minHeight: 0, flexDirection: 'column', borderColor: theme().color.border }} border>
<Show when={current()} fallback={<text fg={theme().color.muted}> </text>}>
{sa => (
<>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.label }}>{sa().goal || sa().id}</span>
<span style={{ fg: statusColor(sa().status, theme) }}>{` · ${sa().status}`}</span>
<span style={{ fg: theme().color.muted }}>{sa().model ? ` · ${sa().model}` : ''}</span>
</text>
</box>
<Show when={sa().thought}>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.muted }}>{`🧠 ${sa().thought}`}</span>
</text>
</box>
</Show>
<box style={{ flexGrow: 1, minHeight: 0, paddingLeft: 1 }}>
<scrollbox
ref={el => (traceBox = el)}
style={{ flexGrow: 1, minHeight: 0 }}
stickyScroll
stickyStart="bottom"
>
<Show
when={(sa().trace?.length ?? 0) > 0}
fallback={<text fg={theme().color.muted}>(no activity yet)</text>}
>
<For each={sa().trace ?? []}>
{line => (
<text>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
</Show>
</scrollbox>
</box>
</>
)}
</Show>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted}>Esc/q close · select · PgUp/PgDn scroll trace</text>
</box>
</box>
)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Pager — a full-height scrollable text viewer (spec §2b `FloatBox` pager).
* Porting it unlocks the long-output slash commands (/status /logs /history
* /tools) at once. Replaces the transcript+composer while open (the App swaps it
* in on `store.state.pager`).
*
* Scrolling is driven explicitly via a GLOBAL `useKeyboard` → `scrollBy`/`scrollTo`
* (no reliance on focus); Esc/Ctrl+C close via the native keymap. Carries the §8 #2
* scrollbox gotchas (minHeight:0 wrapper+box, NO flexDirection on the box root).
*/
import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, onMount } from 'solid-js'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const PAGE = 10
export function Pager(props: { title: string; text: string; onClose: () => void }) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
let box: ScrollBoxRenderable | undefined
const lines = () => props.text.split('\n')
// Close (Esc/Ctrl+C) is the native keymap; scroll keys stay in the raw global
// handler below. Focus the root box on mount so the focus-within close layer is
// active (the scrollbox isn't focused — scroll is global, not focus-gated).
onMount(() => rootRef?.focus())
useCloseLayer(
() => rootRef,
() => props.onClose()
)
useKeyboard(key => {
// `q` closes (the footer advertises "Esc/q close"); Esc/Ctrl+C close via the
// keymap layer above. Scroll stays raw (not focus-gated).
if (key.name === 'q') return props.onClose()
if (!box) return
if (key.name === 'up') box.scrollBy(-1)
else if (key.name === 'down') box.scrollBy(1)
else if (key.name === 'pageup') box.scrollBy(-PAGE)
else if (key.name === 'pagedown') box.scrollBy(PAGE)
else if (key.name === 'home') box.scrollTo(0)
else if (key.name === 'end') box.scrollTo({ x: 0, y: box.scrollHeight })
})
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }}
border
>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>{props.title}</b>
</text>
</box>
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={el => (box = el)} style={{ flexGrow: 1, minHeight: 0 }}>
<For each={lines()}>{line => <text fg={theme().color.text}>{line}</text>}</For>
</scrollbox>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted}>Esc/q close · /PgUp/PgDn/Home/End scroll</text>
</box>
</box>
)
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Picker — a generic titled `<select>` overlay (spec §2b). Powers the model
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
* Native select nav (↑↓/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
* Replaces the composer while open.
*/
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { PickerItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function Picker(props: {
title: string
items: PickerItem[]
onPick: (value: string) => void
onClose: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(
() => rootRef,
() => props.onClose()
)
const options = createMemo(() =>
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b>{props.title}</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
if (option) props.onPick(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter choose · Esc cancel</text>
</box>
)
}
@@ -0,0 +1,60 @@
/**
* SessionSwitcher — pick a session to resume (spec §2b; Ink
* `activeSessionSwitcher.tsx`). A native `<select>` over `session.list` rows;
* Enter resumes the chosen session (the entry runs the same resume-hydrate path
* as launch), Esc/Ctrl+C closes. Replaces the composer while open.
*/
import type { BoxRenderable } from '@opentui/core'
import { createMemo } from 'solid-js'
import type { SessionItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
export function SessionSwitcher(props: {
sessions: SessionItem[]
onPick: (sessionId: string) => void
onClose: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/Enter; the keymap owns Esc/Ctrl+C close.
useCloseLayer(
() => rootRef,
() => props.onClose()
)
const options = createMemo(() =>
props.sessions.map(s => ({
description: `${s.messageCount} msgs${s.preview ? ` · ${s.preview.slice(0, 60)}` : ''}`,
name: s.title || s.preview.slice(0, 48) || s.id,
value: s.id
}))
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b> Resume a session</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
if (option) props.onPick(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter resume · Esc cancel</text>
</box>
)
}
@@ -0,0 +1,61 @@
/**
* ApprovalPrompt — dangerous-command approval (spec §8 #6). Native `<select>`
* (built-in ↑↓/j/k/Enter nav) over once/session/always/deny; a small `useKeyboard`
* adds the Esc/Ctrl+C → deny cancel path the select doesn't cover. Answered via
* `approval.respond {choice, session_id}`.
*/
import type { BoxRenderable } from '@opentui/core'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
const OPTIONS = [
{ description: 'Run this command this one time', name: 'Approve once', value: 'once' },
{ description: 'Allow for the rest of this session', name: 'Approve for session', value: 'session' },
{ description: 'Always allow this command', name: 'Always approve', value: 'always' },
{ description: 'Reject this command', name: 'Deny', value: 'deny' }
]
export function ApprovalPrompt(props: {
command: string
description: string
onChoose: (choice: string) => void
onCancel: () => void
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// Native select handles ↑↓/j/k/Enter over the options; the keymap owns the
// Esc/Ctrl+C → deny cancel path the select doesn't cover.
useCloseLayer(
() => rootRef,
() => props.onCancel()
)
return (
<box
ref={el => (rootRef = el)}
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.warn}>
<b> Approval required</b>
</text>
<text fg={theme().color.text}>{props.command}</text>
{props.description ? <text fg={theme().color.muted}>{props.description}</text> : null}
<select
focused
options={OPTIONS}
onSelect={(_index, option) => {
if (option) props.onChoose(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: 8, marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter confirm · Esc/Ctrl+C deny</text>
</box>
)
}
Binary file not shown.
@@ -0,0 +1,58 @@
/**
* ConfirmPrompt — a LOCAL (non-gateway) Y/N dialog (spec §2a). Driven by a local
* callback, not an RPC: y/Enter → confirm, n/Esc/Ctrl+C → cancel. Used by client
* slash commands like /clear and /new.
*/
import type { BoxRenderable } from '@opentui/core'
import { useBindings } from '@opentui/keymap/solid'
import { onMount } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function ConfirmPrompt(props: { message: string; onYes: () => void; onNo: () => void }) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// No focusable child here (unlike the <select> prompts), so focus the dialog box
// itself on mount — that makes the focus-within keymap layer below active.
onMount(() => rootRef?.focus())
// Local Y/N dialog: y/Enter → confirm, n/Esc/Ctrl+C → cancel, scoped to the
// dialog box (focus-within) via the native keymap.
useBindings<BoxRenderable>(() => ({
target: () => rootRef,
commands: [
{
name: 'confirm',
run() {
props.onYes()
}
},
{
name: 'cancel',
run() {
props.onNo()
}
}
],
bindings: [
{ key: 'y', cmd: 'confirm' },
{ key: 'return', cmd: 'confirm' },
{ key: 'n', cmd: 'cancel' },
{ key: 'escape', cmd: 'cancel' },
{ key: { name: 'c', ctrl: true }, cmd: 'cancel' }
]
}))
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.warn}>
<b>{props.message}</b>
</text>
<text fg={theme().color.muted}>y/Enter confirm · n/Esc cancel</text>
</box>
)
}
@@ -0,0 +1,63 @@
/**
* MaskedPrompt — sudo (🔐) / secret (🔑) masked entry (spec §8 #6). OpenTUI's
* `<input>` has NO native mask (only value/placeholder/maxLength), and feeding it
* stars via `value` is a feedback loop (onInput reports the masked value), so we
* own a hidden buffer and capture raw keystrokes via `useKeyboard`, rendering '*'
* per char — the robust path for masked input (verified in the React build).
*
* Enter submits the real buffer; Esc/Ctrl+C submits empty so the agent unblocks.
*/
import { useKeyboard } from '@opentui/solid'
import { createSignal, Show } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function MaskedPrompt(props: {
icon: string
label: string
sub?: string
onSubmit: (value: string) => void
onCancel: () => void
}) {
const theme = useTheme()
const [value, setValue] = createSignal('')
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
props.onCancel()
return
}
if (key.name === 'return') {
props.onSubmit(value())
return
}
if (key.name === 'backspace') {
setValue(v => v.slice(0, -1))
return
}
const ch = key.sequence ?? ''
if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setValue(v => v + ch)
})
return (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.label}>
<b>
{props.icon} {props.label}
</b>
</text>
<Show when={props.sub}>
<text fg={theme().color.muted}>{props.sub}</text>
</Show>
<box style={{ flexDirection: 'row' }}>
<text fg={theme().color.label}>{'> '}</text>
<text fg={theme().color.text}>{'*'.repeat(value().length)}</text>
<text fg={theme().color.accent}></text>
</box>
<text fg={theme().color.muted}>Enter send · Esc/Ctrl+C cancel · masked</text>
</box>
)
}
@@ -0,0 +1,110 @@
/**
* PromptOverlay — renders the active blocking prompt and binds each answer/cancel
* to the matching `*.respond` RPC (spec §4 reply contract; §8 #6 deadlock fix):
* clarify.respond {answer, request_id} · approval.respond {choice, session_id} ·
* sudo.respond {password, request_id} · secret.respond {value, request_id}.
* Every cancel path (Esc/Ctrl+C) sends the deny/empty reply so the agent unblocks.
*
* `onRespond` is the entry-wired boundary callback (fires `gateway.request`); the
* overlay also clears the store prompt so the composer returns. Narrowing is done
* with reactive `as*()` accessors so each sub-prompt gets its typed payload.
*/
import { Match, Switch } from 'solid-js'
import { deferClose } from '../../logic/defer.ts'
import type { ActivePrompt, SessionStore } from '../../logic/store.ts'
import { ApprovalPrompt } from './approvalPrompt.tsx'
import { ClarifyPrompt } from './clarifyPrompt.tsx'
import { ConfirmPrompt } from './confirmPrompt.tsx'
import { MaskedPrompt } from './maskedPrompt.tsx'
export interface PromptOverlayProps {
readonly store: SessionStore
readonly onRespond: (method: string, params: Record<string, unknown>) => void
readonly sessionId: () => string | undefined
}
export function PromptOverlay(props: PromptOverlayProps) {
const prompt = () => props.store.state.prompt
// Defer the prompt-clear (which remounts + refocuses the composer) past the
// CURRENT keystroke, so the key that answered the prompt (Enter/y/select) can't
// leak into the freshly-focused composer (e.g. `/clear`→y left "y" in the input).
const clearSoon = () => deferClose(() => props.store.clearPrompt())
const respond = (method: string, params: Record<string, unknown>) => {
props.onRespond(method, params)
clearSoon()
}
// Reactive accessor that narrows the active-prompt union to one `kind`, giving
// each <Match> branch its precise typed payload (undefined when not that kind).
function narrow<K extends ActivePrompt['kind']>(kind: K): () => Extract<ActivePrompt, { kind: K }> | undefined {
const matches = (p: ActivePrompt): p is Extract<ActivePrompt, { kind: K }> => p.kind === kind
return () => {
const p = prompt()
return p && matches(p) ? p : undefined
}
}
const asApproval = narrow('approval')
const asClarify = narrow('clarify')
const asSudo = narrow('sudo')
const asSecret = narrow('secret')
const asConfirm = narrow('confirm')
return (
<Switch>
<Match when={asApproval()}>
{p => (
<ApprovalPrompt
command={p().command}
description={p().description}
onChoose={choice => respond('approval.respond', { choice, session_id: props.sessionId() })}
onCancel={() => respond('approval.respond', { choice: 'deny', session_id: props.sessionId() })}
/>
)}
</Match>
<Match when={asClarify()}>
{p => (
<ClarifyPrompt
question={p().question}
choices={p().choices}
onAnswer={answer => respond('clarify.respond', { answer, request_id: p().requestId })}
onCancel={() => respond('clarify.respond', { answer: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSudo()}>
{p => (
<MaskedPrompt
icon="🔐"
label="sudo password"
onSubmit={value => respond('sudo.respond', { password: value, request_id: p().requestId })}
onCancel={() => respond('sudo.respond', { password: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSecret()}>
{p => (
<MaskedPrompt
icon="🔑"
label={`Secret: ${p().envVar}`}
sub={p().prompt}
onSubmit={value => respond('secret.respond', { request_id: p().requestId, value })}
onCancel={() => respond('secret.respond', { request_id: p().requestId, value: '' })}
/>
)}
</Match>
<Match when={asConfirm()}>
{p => (
<ConfirmPrompt
message={p().message}
onYes={() => {
p().onConfirm()
clearSoon()
}}
onNo={clearSoon}
/>
)}
</Match>
</Switch>
)
}
+74
View File
@@ -0,0 +1,74 @@
/**
* ReasoningPart — the model's thinking trace, collapsible (item 6; opencode's
* ReasoningPart/ReasoningHeader). Auto-EXPANDED while the turn streams (so you
* watch it think), then COLLAPSES to a one-line `▶ Thought: <title>` once the
* turn settles. Click the header to override either way.
*
* ▼ Thinking: <title> ← live (streaming), body shown
* ▶ Thought: <title> ← settled (collapsed), click to reopen
* │ <reasoning markdown> ← dim body in a left-bordered block
*
* Title is the model's leading `**bold**` line when present (opencode's
* reasoningSummary). Dim throughout — it's secondary to the answer.
*/
import { createMemo, createSignal, Show } from 'solid-js'
import { Markdown } from './markdown.tsx'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */
function reasoningSummary(text: string): { title?: string; body: string } {
const s = (text ?? '').replace('[REDACTED]', '').trim()
const m = s.match(/^\*\*([^*\n]+)\*\*(?:\r?\n\r?\n|$)/)
const title = m?.[1]?.trim()
if (!m || !title) return { body: s }
return { title, body: s.slice(m[0].length).trimStart() }
}
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
const theme = useTheme()
const anchor = useScrollAnchor()
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
// live → expanded so you see it think; settled → collapsed. Click overrides.
const expanded = () => override() ?? !!props.streaming
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
const summary = createMemo(() => reasoningSummary(props.text))
const label = () => (props.streaming ? 'Thinking' : 'Thought')
return (
<Show when={summary().body || summary().title}>
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={toggle}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{expanded() ? '▼' : '▶'}</span>
</text>
</box>
{/* the header is a collapsible-section LABEL (Thinking/Thought + title)
— chrome, not the reasoning body — so a free-form drag yields only
the markdown body below, not the section label (item 4). */}
<text selectable={false}>
{/* accent chevron marks it; muted label keeps reasoning in the dim,
secondary tier alongside tool calls (Ink hierarchy). */}
<span style={{ fg: theme().color.muted }}>{label()}</span>
<Show when={summary().title}>
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
</Show>
</text>
</box>
<Show when={expanded() && summary().body}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={theme().color.border}
>
<Markdown text={summary().body} streaming={props.streaming ?? false} fg={theme().color.muted} />
</box>
</Show>
</box>
</Show>
)
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Scroll anchoring for collapse/expand toggles (item #4). The transcript
* <scrollbox> has stickyScroll+stickyStart="bottom": on a content-height change
* it re-pins to the bottom whenever the user hasn't manually scrolled away
* (@opentui/core ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`).
* So expanding a tool/thinking block while at the bottom yanks the viewport to the
* NEW bottom — scrolling the header you just clicked up off-screen.
*
* Fix: keep scrollTop constant across the toggle. The clicked element's document
* position is unchanged (content grows BELOW it), so holding scrollTop keeps that
* header at the same screen row and simply reveals the expansion beneath it. We
* re-assert the saved offset over a few frames because the content height (and the
* sticky re-pin) only settle on the next render pass.
*/
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
import type { ScrollBoxRenderable } from '@opentui/core'
type AnchorFn = (toggle: () => void) => void
const Ctx = createContext<AnchorFn>()
export function ScrollAnchorProvider(props: {
scroll: Accessor<ScrollBoxRenderable | undefined>
children: JSX.Element
}) {
const around: AnchorFn = toggle => {
const sb = props.scroll()
if (!sb) {
toggle()
return
}
const prev = sb.scrollTop
toggle()
// Re-assert across the next few frames: the layout + sticky re-pin land on
// subsequent render passes, so a single sync restore wouldn't hold.
let n = 0
const hold = () => {
try {
sb.scrollTo(prev)
} catch {
/* renderable torn down */
}
if (++n < 4) setTimeout(hold, 16)
}
setTimeout(hold, 0)
}
return <Ctx.Provider value={around}>{props.children}</Ctx.Provider>
}
/** Wrap a collapse/expand toggle so the viewport stays put (no-op outside a provider). */
export function useScrollAnchor(): AnchorFn {
return useContext(Ctx) ?? (toggle => toggle())
}
+144
View File
@@ -0,0 +1,144 @@
/**
* StatusBar — the persistent bottom chrome (spec §3; Ink's `appChrome.tsx`
* StatusRule, item 14). One themed row pinned below the input zone:
*
* ● model ·effort ████░░░░ 42% ~/dir (branch)
*
* Fields are sourced from `store.state.info` (the `session.info` event +
* session.create/resume result; see store `SessionInfo`). Width-aware (Ink's
* `statusRuleWidths` progressive disclosure): the context bar drops on narrow
* terminals and the cwd is left-truncated (`…/tail`) so the row NEVER wraps or
* clips. Read-only chrome — no input handling here.
*/
import { useDimensions } from './dimensions.tsx'
import { createMemo, Show } from 'solid-js'
import { useTheme } from './theme.tsx'
import type { SessionStore } from '../logic/store.ts'
const HOME = process.env.HOME ?? ''
const CTX_BAR_CELLS = 8
/** `anthropic/claude-opus-4-8` → `claude-opus-4-8`; trims the provider prefix (Ink shortModelLabel). */
function shortModel(model: string): string {
return model.includes('/') ? (model.split('/').at(-1) ?? model) : model
}
/** Reasoning effort → a compact suffix; hidden for the default/medium effort. */
function effortSuffix(effort: string | undefined, fast: boolean | undefined): string {
const parts: string[] = []
if (effort && effort !== 'medium' && effort !== 'default') parts.push(effort)
if (fast) parts.push('fast')
return parts.length ? ` ·${parts.join('·')}` : ''
}
/** Abbreviate cwd with `~` for $HOME, then collapse to the last two path segments
* (`…/lively-thrush/hermes-agent`) so deep worktree paths stay readable (Ink fmtCwdBranch). */
function shortCwd(cwd: string): string {
const home = HOME && (cwd === HOME || cwd.startsWith(HOME + '/')) ? '~' + cwd.slice(HOME.length) : cwd
const segs = home.split('/').filter(Boolean)
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
}
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
function truncLeft(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
}
/** A unicode meter: `████░░░░` filled to `pct`% over `width` cells (Ink ctxBar). */
function ctxBar(pct: number, width: number): string {
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
return '█'.repeat(filled) + '░'.repeat(width - filled)
}
export function StatusBar(props: { store: SessionStore }) {
const theme = useTheme()
const dims = useDimensions()
const info = () => props.store.state.info
// Context-bar colour escalates with pressure (Ink ctxBarColor good→warn→bad→critical).
const ctxColor = (pct: number) =>
pct >= 92
? theme().color.statusCritical
: pct >= 80
? theme().color.statusBad
: pct >= 60
? theme().color.statusWarn
: theme().color.statusGood
const dot = () => (info().running ? '◐' : props.store.state.ready ? '●' : '○')
const dotColor = () =>
info().running ? theme().color.statusWarn : props.store.state.ready ? theme().color.statusGood : theme().color.muted
const model = () => {
const m = info().model
return m ? shortModel(m) : ''
}
const effort = () => effortSuffix(info().effort, info().fast)
const pct = () => info().contextPercent
// Progressive disclosure budget (the row is `width - 2` after the box padding).
// left = dot+space+model+effort ; the context bar shows only when there's room.
const showBar = createMemo(() => pct() !== undefined && dims().width >= 64)
const ctxText = () => {
const p = pct()
return showBar() && p !== undefined ? `${ctxBar(p, CTX_BAR_CELLS)} ${p}%` : ''
}
// Right side: cwd (branch), left-truncated to whatever the left side leaves.
const cwdFull = createMemo(() => {
const cwd = info().cwd
const c = cwd ? shortCwd(cwd) : ''
if (!c) return ''
return info().branch ? `${c} (${info().branch})` : c
})
const rightText = createMemo(() => {
const leftLen = 2 + model().length + effort().length + (showBar() ? ctxText().length + 3 : 0)
const budget = dims().width - 2 - leftLen - 2 // box padding + a 2-col gap
return budget > 4 ? truncLeft(cwdFull(), budget) : ''
})
return (
<box
style={{
flexShrink: 0,
flexDirection: 'row',
backgroundColor: theme().color.statusBg,
paddingLeft: 1,
paddingRight: 1
}}
>
{/* left: turn/connection dot + model + effort + context bar */}
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: dotColor() }}>{dot()}</span>
<Show when={model()}>
<span style={{ fg: theme().color.statusFg }}>{` ${model()}`}</span>
<span style={{ fg: theme().color.muted }}>{effort()}</span>
</Show>
<Show when={showBar()}>
{/* a dim divider segments the bar into scannable fields (item 8).
showBar() already guarantees pct() is defined; `?? 0` only
satisfies the type and is never reached. */}
<span style={{ fg: theme().color.border }}>{' │ '}</span>
<span style={{ fg: ctxColor(pct() ?? 0) }}>{ctxBar(pct() ?? 0, CTX_BAR_CELLS)}</span>
<span style={{ fg: theme().color.statusFg }}>{` ${pct()}%`}</span>
</Show>
</text>
</box>
{/* spacer pushes the cwd to the right edge */}
<box style={{ flexGrow: 1, minWidth: 0 }} />
{/* right: cwd (branch), pre-truncated so the row never wraps */}
<Show when={rightText()}>
<box style={{ flexShrink: 0, flexDirection: 'row' }}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{rightText()}</span>
</text>
</box>
</Show>
</box>
)
}
+31
View File
@@ -0,0 +1,31 @@
/**
* StatusLine — the transient line just below the transcript (spec §3 chrome).
* Shows EITHER:
* - a `hint` (e.g. "Ctrl+C again to quit" — item 11), in the warn colour and
* taking priority; or
* - the kaomoji busy face/verb from `thinking.delta`/`status.update` WHILE a
* turn runs (Ink's FaceTicker), dim, cleared on `message.complete`.
* This keeps those transient indicators OUT of the transcript. Renders nothing
* when both are idle.
*/
import { Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function StatusLine(props: { store: SessionStore }) {
const theme = useTheme()
const line = () => props.store.state.hint ?? props.store.state.status
const isHint = () => props.store.state.hint !== undefined
return (
<Show when={line()}>
{text => (
<box style={{ flexShrink: 0 }}>
<text selectable={false}>
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
</text>
</box>
)}
</Show>
)
}
+29
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)
}
+204
View File
@@ -0,0 +1,204 @@
/**
* ToolPart — one tool call, rendered COLLAPSED by default with a clear expand
* affordance (items 2 + 7). The header shows the tool's PRIMARY ARG inline so
* you can read what it did without expanding (item 2 — "I don't see tool args"):
*
* ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default)
* ▼ terminal ls -la src · 0.3s ← expanded header
* │ args { … } ← full args (when present)
* │ output … ← envelope-stripped body
* │ … omitted 5 lines / 234 chars ← tidy note (no raw label)
*
* `▶`/`▼` marks expandable tools; clicking the header toggles it. Running tools
* show `name …`. `resultText`/`omittedNote` are already cleaned by the store.
* Fully themed (no hardcoded styles); decorative glyphs are selectable={false}.
*/
import { type ToolPartState } from '../logic/store.ts'
import { useDimensions } from './dimensions.tsx'
import { createMemo, createSignal, For, Show } from 'solid-js'
import { collapseToolOutput, truncate } from '../logic/toolOutput.ts'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Max output lines shown when expanded (a sane cap to avoid huge renders). */
const EXPANDED_MAX = 200
/** Max args lines shown when expanded. */
const ARGS_MAX = 16
function fmtDuration(s: number): string {
if (s < 10) return `${s.toFixed(1)}s`
if (s < 60) return `${Math.round(s)}s`
const m = Math.floor(s / 60)
const r = Math.round(s % 60)
return r ? `${m}m ${r}s` : `${m}m`
}
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useDimensions()
const anchor = useScrollAnchor()
const [expanded, setExpanded] = createSignal(false)
const toggle = () => anchor(() => setExpanded(e => !e))
const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4)
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
const lines = () => (result() ? result().split('\n') : [])
const running = () => props.part.state === 'running'
const hasOutput = () => lines().length > 0
// Parse the args JSON into top-level key→value entries for a tidy key:value
// render (no brace noise). Falls back to raw lines when it isn't an object.
const argsObj = createMemo<Record<string, unknown> | undefined>(() => {
const t = props.part.argsText
if (!t) return undefined
try {
const o: unknown = JSON.parse(t)
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : undefined
} catch {
return undefined
}
})
const argLine = (k: string, v: unknown) =>
`${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`.replace(/\s+/g, ' ')
const argEntries = createMemo(() => Object.entries(argsObj() ?? {}))
// Hide the args block when it adds nothing over the header: a single field
// whose value is already the primary-arg preview (item 2 judge nit — terminal's
// `command` is redundant). Show it for multi-field tools (edits, reads w/ range).
const showArgs = createMemo(() => {
const e = argEntries()
if (argsObj() === undefined) return !!props.part.argsText // unparsed → show raw
if (e.length === 0) return false
const only = e.length === 1 ? e[0] : undefined
if (only) {
const v = only[1]
const vs = (typeof v === 'string' ? v : JSON.stringify(v)).trim()
return vs !== (props.part.argsPreview ?? '').trim()
}
return true
})
// Expandable when there's a body to reveal beyond the header (output or args).
const collapsible = () => !running() && (lines().length > 1 || showArgs())
// Header subtitle: the primary-arg preview (item 2), else explicit summary, else first line.
const subtitle = () =>
props.part.error ? `${props.part.error}` : props.part.argsPreview || props.part.summary || lines()[0] || ''
const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, bodyWidth() - 2))
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
const headColor = () => (props.part.error ? theme().color.error : theme().color.accent)
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
return (
// Spacing between parts is owned by the parts column (gap), not per-part
// margins — so a tool appearing mid-stream doesn't shift the layout (item 5).
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
{/* header — clickable to toggle when there's expandable output/args */}
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && toggle()}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: headColor() }}>{headGlyph()}</span>
</text>
</box>
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0 }}>
{/* the whole header row is a collapsed SUMMARY (tool name + args-preview
+ duration + "(N lines)") — chrome, not the copyable body — so a
free-form drag over a tool yields only the expanded output/args
content, never the header label (item 4). */}
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
<Show when={running()}>
<span style={{ fg: theme().color.muted }}> </span>
</Show>
<Show when={!running() && subtitle()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${truncate(subtitle(), subWidth())}`}
</span>
</Show>
<Show when={!running() && props.part.duration !== undefined}>
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
</Show>
<Show when={collapsible() && !expanded() && lines().length > 1}>
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
</Show>
</text>
</box>
</box>
{/* expanded body — args block (when present) then output block, inside a
single left-bordered column (a `│` rule, not a bg fill — opencode's
BlockTool style; also renders faithfully and reads cleaner). */}
<Show when={collapsible() && expanded()}>
<box
style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, marginLeft: GUTTER, paddingLeft: 1 }}
border={['left']}
borderColor={props.part.error ? theme().color.error : theme().color.border}
>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={showArgs()}>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>args</span>
</text>
{/* parsed key: value lines (tidy), or raw argsText when unparseable */}
<Show
when={argsObj() !== undefined}
fallback={
<For each={(props.part.argsText ?? '').split('\n').slice(0, ARGS_MAX)}>
{line => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{truncate(line, bodyWidth() - 2)}</span>
</text>
)}
</For>
}
>
<For each={argEntries().slice(0, ARGS_MAX)}>
{([k, v]) => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{truncate(argLine(k, v), bodyWidth() - 2)}</span>
</text>
)}
</For>
<Show when={argEntries().length > ARGS_MAX}>
{/* overflow annotation — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${argEntries().length - ARGS_MAX} more`}</span>
</text>
</Show>
</Show>
</Show>
<Show when={showArgs() && hasOutput()}>
{/* section label — chrome, not content (item 4) */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>output</span>
</text>
</Show>
{/* output body lines are the copyable content → themed selection bar
(preserves fg; same token as message text) (item: theme highlight). */}
<For each={body().lines}>
{line => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
{/* truncation annotations — chrome (the "… omitted N" / "… +N more
lines" notes are not part of the real output body) (item 4). */}
<Show when={props.part.omittedNote}>
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
</text>
</Show>
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
</text>
</Show>
</box>
</box>
</Show>
</box>
)
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Transcript — the scrolling message pane (spec v4 §2 `view/transcript.tsx`).
*
* ONE full-height <scrollbox> with a reactive <For> (opencode's model — the
* viewport clips growing output so terminal scrollback is never corrupted; no
* `writeToScrollback`). Carries the §8 #2 gotchas EXACTLY:
* - `minHeight:0` on BOTH the wrapper box AND the <scrollbox> (so the flex
* child can shrink below content height instead of pushing the composer off),
* - NO `flexDirection` on the <scrollbox> ROOT style (it has internal
* viewport/content children; setting it there breaks content-height
* measurement → phantom scroll offset that clips the top + leaves a gap),
* - `stickyScroll` + `stickyStart="bottom"` to pin the latest line.
*
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
*/
import type { ScrollBoxRenderable } from '@opentui/core'
import { createSignal, For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { HomeHint } from './homeHint.tsx'
import { MessageLine } from './messageLine.tsx'
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
export function Transcript(props: { store: SessionStore }) {
const [scroll, setScroll] = createSignal<ScrollBoxRenderable | undefined>()
const theme = useTheme()
const dropped = () => props.store.state.dropped
const sid = () => props.store.state.sessionId
return (
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<ScrollAnchorProvider scroll={scroll}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
<Show when={props.store.state.messages.length === 0}>
<HomeHint store={props.store} />
</Show>
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
DISPLAY (never the model's context — that lives on the gateway). Point to
the dashboard for the full transcript. selectable=false → it's chrome,
excluded from copy/selection. */}
<Show when={dropped() > 0}>
<text selectable={false} style={{ fg: theme().color.muted }}>
{`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text>
</Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
</ScrollAnchorProvider>
</scrollbox>
</box>
)
}