Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+483
View File
@@ -0,0 +1,483 @@
// Deterministic virtual-history benchmark. The file intentionally uses only
// APIs present before the performance candidate so the exact same script can
// be copied/run on base and candidate checkouts.
//
// Run from ui-tui:
// npx tsx scripts/bench-history-scroll.tsx
// npx tsx scripts/bench-history-scroll.tsx --warmups=2 --samples=5 --items=100,1000,10000
//
// In addition to the virtual-history workloads, every run mounts one
// oversized bordered/fill box at each RENDERER_EXTENT inside the fixed
// viewport. Keeping that tree to a few Yoga nodes isolates renderer clipping
// from node-construction cost and makes the workload revision-comparable.
import { PassThrough } from 'stream'
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import React, { useLayoutEffect, useRef } from 'react'
import { useVirtualHistory } from '../src/hooks/useVirtualHistory.js'
const DEFAULT_WORKLOADS = [100, 1_000, 10_000]
const RENDERER_EXTENTS = [100, 1_000, 10_000]
const DEFAULT_WARMUPS = 1
const DEFAULT_SAMPLES = 5
const COLUMNS = 100
const ROWS = 30
const MAX_MOUNTED = 120
interface BenchItem {
height: number
key: string
text: string
}
interface Exposed {
scroll: ScrollBoxHandle | null
virtual: ReturnType<typeof useVirtualHistory>
}
interface Sample {
anchorError: number
heapDeltaBytes: number | null
invalidOffsets: number
measuredHeightReconciliationMs: number
mountMs: number
mountedRowsMax: number
nonMonotoneOffsets: number
rerenderMs: number
scrollMs: number
terminalBytes: number
terminalWrites: number
}
interface WorkloadResult {
distributions: {
anchorError: ReturnType<typeof distribution>
heapDeltaBytes: ReturnType<typeof distribution>
measuredHeightReconciliationMs: ReturnType<typeof distribution>
mountMs: ReturnType<typeof distribution>
mountedRowsMax: ReturnType<typeof distribution>
rerenderMs: ReturnType<typeof distribution>
scrollMs: ReturnType<typeof distribution>
terminalBytes: ReturnType<typeof distribution>
terminalWrites: ReturnType<typeof distribution>
}
invalidOffsets: number
itemCount: number
nonMonotoneOffsets: number
samples: Sample[]
}
interface OversizedRendererSample {
freshMountRenderMs: number
terminalBytes: number
terminalWrites: number
}
interface OversizedRendererResult {
distributions: {
freshMountRenderMs: ReturnType<typeof distribution>
terminalBytes: ReturnType<typeof distribution>
terminalWrites: ReturnType<typeof distribution>
}
extent: number
samples: OversizedRendererSample[]
}
class CountingStream extends PassThrough {
columns = COLUMNS
rows = ROWS
isTTY = false
bytes = 0
writes = 0
override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
this.bytes += Buffer.byteLength(chunk)
this.writes++
callback()
}
}
const immediate = () => new Promise<void>(resolve => setImmediate(resolve))
async function settle(frames = 4) {
for (let frame = 0; frame < frames; frame++) {
await immediate()
}
}
async function waitUntil(predicate: () => boolean, attempts = 40) {
for (let attempt = 0; attempt < attempts; attempt++) {
if (predicate()) {
return true
}
await immediate()
}
return predicate()
}
function makeItems(count: number): BenchItem[] {
return Array.from({ length: count }, (_, index) => ({
height: 1 + ((index * 17) % 4),
key: `row-${index}`,
text: `row ${index} ${'history '.repeat(2 + (index % 5))}`
}))
}
function Harness({ expose, items }: { expose: React.MutableRefObject<Exposed | null>; items: readonly BenchItem[] }) {
const scrollRef = useRef<ScrollBoxHandle | null>(null)
const virtual = useVirtualHistory(scrollRef, items, COLUMNS, {
coldStartCount: 30,
estimateHeight: index => items[index]?.height ?? 1,
maxMounted: MAX_MOUNTED,
overscan: 20
})
useLayoutEffect(() => {
expose.current = { scroll: scrollRef.current, virtual }
})
return (
<ScrollBox flexDirection="column" height={ROWS} ref={scrollRef} stickyScroll>
<Box flexDirection="column" width="100%">
{virtual.topSpacer > 0 ? <Box height={virtual.topSpacer} /> : null}
{items.slice(virtual.start, virtual.end).map(item => (
<Box height={item.height} key={item.key} ref={virtual.measureRef(item.key)}>
<Text>{item.text}</Text>
</Box>
))}
{virtual.bottomSpacer > 0 ? <Box height={virtual.bottomSpacer} /> : null}
</Box>
</ScrollBox>
)
}
function OversizedRendererHarness({ extent }: { extent: number }) {
return (
<ScrollBox flexDirection="column" height={ROWS} width={COLUMNS}>
<Box backgroundColor="ansi:blue" borderStyle="single" flexShrink={0} height={extent} opaque width={COLUMNS}>
<Text>deterministic oversized height workload</Text>
</Box>
<Box backgroundColor="ansi:magenta" borderStyle="single" height={ROWS} opaque position="absolute" width={extent}>
<Text>deterministic oversized width workload</Text>
</Box>
</ScrollBox>
)
}
function inspectOffsets(offsets: ArrayLike<number>, count: number) {
let invalidOffsets = 0
let nonMonotoneOffsets = 0
for (let index = 0; index <= count; index++) {
const value = offsets[index]
if (!Number.isFinite(value)) {
invalidOffsets++
}
if (index > 0 && value! < offsets[index - 1]!) {
nonMonotoneOffsets++
}
}
return { invalidOffsets, nonMonotoneOffsets }
}
async function runSample(itemCount: number): Promise<Sample> {
const stdout = new CountingStream()
const stderr = new CountingStream()
const stdin = new PassThrough()
const expose = { current: null as Exposed | null }
let items = makeItems(itemCount)
const heapBefore = process.memoryUsage?.().heapUsed ?? null
const mountStart = performance.now()
const instance = renderSync(<Harness expose={expose} items={items} />, {
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
await waitUntil(() => expose.current?.scroll !== null)
await settle()
const mountMs = performance.now() - mountStart
let mountedRowsMax = expose.current!.virtual.end - expose.current!.virtual.start
const rerenderItems = items.map((item, index) =>
index === items.length - 1 ? { ...item, text: `${item.text} rerender` } : item
)
const rerenderStart = performance.now()
instance.rerender(<Harness expose={expose} items={rerenderItems} />)
await settle()
const rerenderMs = performance.now() - rerenderStart
items = rerenderItems
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const scroll = expose.current!.scroll!
const total = expose.current!.virtual.offsets[itemCount] ?? 0
const scrollStart = performance.now()
scroll.scrollTo(Math.max(0, Math.floor(total * 0.55)))
await settle(8)
const scrollMs = performance.now() - scrollStart
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const beforeOffsets = expose.current!.virtual.offsets
const beforeTop = scroll.getScrollTop()
let measuredIndex = expose.current!.virtual.start
while (measuredIndex + 1 < expose.current!.virtual.end && (beforeOffsets[measuredIndex + 1] ?? 0) > beforeTop) {
measuredIndex++
}
if ((beforeOffsets[measuredIndex + 1] ?? Number.POSITIVE_INFINITY) > beforeTop) {
measuredIndex = Math.max(expose.current!.virtual.start, measuredIndex - 1)
}
const heightDelta = 3
const oldTotal = beforeOffsets[itemCount] ?? 0
const measuredItems = items.map((item, index) =>
index === measuredIndex ? { ...item, height: item.height + heightDelta } : item
)
const reconcileStart = performance.now()
instance.rerender(<Harness expose={expose} items={measuredItems} />)
await waitUntil(() => (expose.current!.virtual.offsets[itemCount] ?? 0) === oldTotal + heightDelta)
await settle(2)
const measuredHeightReconciliationMs = performance.now() - reconcileStart
const measuredWasAbove = (beforeOffsets[measuredIndex + 1] ?? 0) <= beforeTop
const expectedTop = beforeTop + (measuredWasAbove ? heightDelta : 0)
const anchorError = Math.abs(scroll.getScrollTop() - expectedTop)
mountedRowsMax = Math.max(mountedRowsMax, expose.current!.virtual.end - expose.current!.virtual.start)
const offsetHealth = inspectOffsets(expose.current!.virtual.offsets, itemCount)
const heapAfter = process.memoryUsage?.().heapUsed ?? null
const heapDeltaBytes = heapBefore === null || heapAfter === null ? null : heapAfter - heapBefore
const terminalBytes = stdout.bytes
const terminalWrites = stdout.writes
instance.unmount()
instance.cleanup()
stdin.destroy()
stdout.destroy()
stderr.destroy()
return {
anchorError,
heapDeltaBytes,
...offsetHealth,
measuredHeightReconciliationMs,
mountMs,
mountedRowsMax,
rerenderMs,
scrollMs,
terminalBytes,
terminalWrites
}
}
async function runOversizedRendererSample(extent: number): Promise<OversizedRendererSample> {
const stdout = new CountingStream()
const stderr = new CountingStream()
const stdin = new PassThrough()
const mountStart = performance.now()
let instance: ReturnType<typeof renderSync> | undefined
try {
instance = renderSync(<OversizedRendererHarness extent={extent} />, {
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
})
const rendered = await waitUntil(() => stdout.writes > 0)
if (!rendered) {
throw new Error(`oversized renderer extent ${extent} did not produce a terminal frame`)
}
return {
freshMountRenderMs: performance.now() - mountStart,
terminalBytes: stdout.bytes,
terminalWrites: stdout.writes
}
} finally {
instance?.unmount()
instance?.cleanup()
stdin.destroy()
stdout.destroy()
stderr.destroy()
}
}
function distribution(values: number[]) {
const sorted = [...values].sort((a, b) => a - b)
const percentile = (p: number) =>
sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1))] ?? 0
return {
max: sorted.at(-1) ?? 0,
mean: sorted.reduce((sum, value) => sum + value, 0) / Math.max(1, sorted.length),
min: sorted[0] ?? 0,
p50: percentile(0.5),
p95: percentile(0.95),
p99: percentile(0.99)
}
}
function numericArg(name: string, fallback: number) {
const raw = process.argv
.slice(2)
.find(arg => arg.startsWith(`--${name}=`))
?.split('=', 2)[1]
const parsed = Number(raw)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback
}
function workloadsArg() {
const raw = process.argv
.slice(2)
.find(arg => arg.startsWith('--items='))
?.split('=', 2)[1]
if (!raw) {
return DEFAULT_WORKLOADS
}
const parsed = raw.split(',').map(Number)
if (parsed.some(value => !Number.isSafeInteger(value) || value <= 0)) {
throw new Error(`invalid --items workload list: ${raw}`)
}
return parsed
}
async function main() {
const workloads = workloadsArg()
const warmups = numericArg('warmups', DEFAULT_WARMUPS)
const samplesPerWorkload = numericArg('samples', DEFAULT_SAMPLES)
const results: WorkloadResult[] = []
const oversizedRendererResults: OversizedRendererResult[] = []
for (const itemCount of workloads) {
for (let warmup = 0; warmup < warmups; warmup++) {
await runSample(itemCount)
}
const samples: Sample[] = []
for (let sample = 0; sample < samplesPerWorkload; sample++) {
samples.push(await runSample(itemCount))
}
results.push({
itemCount,
distributions: {
anchorError: distribution(samples.map(sample => sample.anchorError)),
heapDeltaBytes: distribution(samples.flatMap(sample => sample.heapDeltaBytes ?? [])),
measuredHeightReconciliationMs: distribution(samples.map(sample => sample.measuredHeightReconciliationMs)),
mountMs: distribution(samples.map(sample => sample.mountMs)),
mountedRowsMax: distribution(samples.map(sample => sample.mountedRowsMax)),
rerenderMs: distribution(samples.map(sample => sample.rerenderMs)),
scrollMs: distribution(samples.map(sample => sample.scrollMs)),
terminalBytes: distribution(samples.map(sample => sample.terminalBytes)),
terminalWrites: distribution(samples.map(sample => sample.terminalWrites))
},
invalidOffsets: samples.reduce((sum, sample) => sum + sample.invalidOffsets, 0),
nonMonotoneOffsets: samples.reduce((sum, sample) => sum + sample.nonMonotoneOffsets, 0),
samples
})
}
for (const extent of RENDERER_EXTENTS) {
for (let warmup = 0; warmup < warmups; warmup++) {
await runOversizedRendererSample(extent)
}
const samples: OversizedRendererSample[] = []
for (let sample = 0; sample < samplesPerWorkload; sample++) {
samples.push(await runOversizedRendererSample(extent))
}
oversizedRendererResults.push({
distributions: {
freshMountRenderMs: distribution(samples.map(sample => sample.freshMountRenderMs)),
terminalBytes: distribution(samples.map(sample => sample.terminalBytes)),
terminalWrites: distribution(samples.map(sample => sample.terminalWrites))
},
extent,
samples
})
}
const scaling = results.slice(1).map((result, index) => {
const previous = results[index]!
const ratio = (metric: keyof (typeof result)['distributions']) =>
result.distributions[metric].p50 / Math.max(Number.EPSILON, previous.distributions[metric].p50)
return {
fromItems: previous.itemCount,
itemFactor: result.itemCount / previous.itemCount,
measuredHeightReconciliationP50Factor: ratio('measuredHeightReconciliationMs'),
mountP50Factor: ratio('mountMs'),
rerenderP50Factor: ratio('rerenderMs'),
scrollP50Factor: ratio('scrollMs'),
terminalBytesP50Factor: ratio('terminalBytes'),
toItems: result.itemCount
}
})
const oversizedRendererScaling = oversizedRendererResults.slice(1).map((result, index) => {
const previous = oversizedRendererResults[index]!
const ratio = (metric: keyof (typeof result)['distributions']) =>
result.distributions[metric].p50 / Math.max(Number.EPSILON, previous.distributions[metric].p50)
return {
extentFactor: result.extent / previous.extent,
freshMountRenderP50Factor: ratio('freshMountRenderMs'),
fromExtent: previous.extent,
terminalBytesP50Factor: ratio('terminalBytes'),
terminalWritesP50Factor: ratio('terminalWrites'),
toExtent: result.extent
}
})
process.stdout.write(
`${JSON.stringify(
{
config: { columns: COLUMNS, maxMounted: MAX_MOUNTED, rows: ROWS, samples: samplesPerWorkload, warmups },
oversizedRenderer: {
extents: RENDERER_EXTENTS,
results: oversizedRendererResults,
scaling: oversizedRendererScaling
},
results,
scaling,
workloads
},
null,
2
)}\n`
)
}
await main()
+302
View File
@@ -0,0 +1,302 @@
// Benchmark: streamed-markdown render strategies for the TUI.
//
// Replays a newline-terminated, block-heavy synthetic stream at width 80
// through a real Ink render (renderSync + rerender per update) and compares:
//
// naive — <Md text={full}/> per update (re-tokenizes everything)
// monolithic — the previous StreamingMd: one memoized stable-prefix <Md>
// plus a tail <Md>. O(total) fence scan per update and a
// full prefix re-parse every time the boundary advances.
// per-block — current StreamingMd: append-only settled block array +
// incremental scanner. Each block parses exactly once.
//
// Each strategy/size pair runs in its OWN child process (orchestrator mode)
// so the parse-tree LRU in markdown.tsx and GC pressure from one strategy
// can't distort another's numbers. Each run also gets a unique text salt and
// a fresh theme object (its own WeakMap cache bucket).
//
// Run: npx tsx scripts/bench-streaming-md.tsx
// Single case: npx tsx scripts/bench-streaming-md.tsx <naive|monolithic|per-block> <blocks>
import { execFileSync } from 'child_process'
import { PassThrough } from 'stream'
import { Box, renderSync } from '@hermes/ink'
import React, { memo, useRef } from 'react'
import { Md } from '../src/components/markdown.js'
import { StreamingMd } from '../src/components/streamingMarkdown.js'
import { DEFAULT_THEME } from '../src/theme.js'
// ---- previous implementation (monolithic stable prefix), for comparison ----
const fenceOpenAt = (s: string, end: number) => {
let codeOpen = false
let mathOpen = false
let mathOpener: '$$' | '\\[' | null = null
let i = 0
while (i < end) {
const nl = s.indexOf('\n', i)
const lineEnd = nl < 0 || nl > end ? end : nl
const line = s.slice(i, lineEnd).trim()
if (/^(?:`{3,}|~{3,})/.test(line)) {
codeOpen = !codeOpen
} else if (!codeOpen) {
if (!mathOpen && /^\$\$/.test(line)) {
if (!(line.length >= 4 && /\$\$$/.test(line))) {
mathOpen = true
mathOpener = '$$'
}
} else if (!mathOpen && /^\\\[/.test(line)) {
if (!/\\\]$/.test(line)) {
mathOpen = true
mathOpener = '\\['
}
} else if (mathOpen && mathOpener === '$$' && /\$\$$/.test(line)) {
mathOpen = false
mathOpener = null
} else if (mathOpen && mathOpener === '\\[' && /\\\]$/.test(line)) {
mathOpen = false
mathOpener = null
}
}
if (nl < 0 || nl >= end) {
break
}
i = nl + 1
}
return codeOpen || mathOpen
}
const findStableBoundaryOld = (text: string) => {
let idx = text.length
while (idx > 0) {
const boundary = text.lastIndexOf('\n\n', idx - 1)
if (boundary < 0) {
return -1
}
const splitAt = boundary + 2
if (!fenceOpenAt(text, splitAt)) {
return splitAt
}
idx = boundary
}
return -1
}
const MonolithicStreamingMd = memo(function MonolithicStreamingMd({
t,
text
}: {
t: typeof DEFAULT_THEME
text: string
}) {
const stablePrefixRef = useRef('')
if (!text.startsWith(stablePrefixRef.current)) {
stablePrefixRef.current = ''
}
const boundary = findStableBoundaryOld(text)
if (boundary > stablePrefixRef.current.length) {
stablePrefixRef.current = text.slice(0, boundary)
}
const stablePrefix = stablePrefixRef.current
const unstableSuffix = text.slice(stablePrefix.length)
if (!stablePrefix) {
return <Md t={t} text={unstableSuffix} />
}
if (!unstableSuffix) {
return <Md t={t} text={stablePrefix} />
}
return (
<Box flexDirection="column">
<Md t={t} text={stablePrefix} />
<Md t={t} text={unstableSuffix} />
</Box>
)
})
// ---- synthetic stream ----
const makeBlocks = (count: number, salt: string) => {
const blocks: string[] = []
for (let i = 0; i < count; i++) {
switch (i % 4) {
case 0:
blocks.push(`Paragraph ${salt}-${i} explaining step ${i} with **bold** and \`code\` inline.\n`)
break
case 1:
blocks.push(`- item one ${salt}-${i}\n- item two with _emphasis_\n- item three\n`)
break
case 2:
blocks.push(`\`\`\`ts\nconst v${i} = compute${salt}(${i})\nif (v${i} > 0) {\n emit(v${i})\n}\n\`\`\`\n`)
break
default:
blocks.push(`### Heading ${salt}-${i}\n\nSome follow-up prose for section ${i}.\n`)
}
}
return blocks
}
// Newline-terminated updates: the stream grows one line per rerender.
const makeUpdates = (blocks: string[]) => {
const full = blocks.join('\n')
const updates: string[] = []
let pos = 0
while (pos < full.length) {
const nl = full.indexOf('\n', pos)
pos = nl < 0 ? full.length : nl + 1
updates.push(full.slice(0, pos))
}
return updates
}
const nullStream = () => {
const s = new PassThrough()
Object.assign(s, { columns: 80, isTTY: false, rows: 24 })
s.on('data', () => {})
return s
}
const bench = (
label: string,
updates: string[],
node: (t: typeof DEFAULT_THEME, text: string) => React.ReactNode,
captureSeries = false
) => {
// Fresh theme per run → fresh (collectable) mdCache bucket.
const runTheme = { ...DEFAULT_THEME }
const instance = renderSync(node(runTheme, ''), {
patchConsole: false,
stderr: nullStream() as unknown as NodeJS.WriteStream,
stdin: nullStream() as unknown as NodeJS.ReadStream,
stdout: nullStream() as unknown as NodeJS.WriteStream
})
const times: number[] = []
const start = performance.now()
let n = 0
for (const text of updates) {
const t0 = captureSeries ? performance.now() : 0
instance.rerender(node(runTheme, text))
if (captureSeries) {
times.push(performance.now() - t0)
}
// Something in the render path emits performance measures; unbounded,
// the entry buffer itself becomes a memory leak over thousands of
// rerenders and skews long runs.
if (++n % 64 === 0) {
performance.clearMeasures()
performance.clearMarks()
}
}
const elapsed = performance.now() - start
instance.unmount()
instance.cleanup()
return { elapsed, label, times }
}
const STRATEGIES = {
monolithic: (t: typeof DEFAULT_THEME, text: string) => <MonolithicStreamingMd t={t} text={text} />,
naive: (t: typeof DEFAULT_THEME, text: string) => <Md t={t} text={text} />,
'per-block': (t: typeof DEFAULT_THEME, text: string) => <StreamingMd t={t} text={text} />
} as const
const [strategyArg, sizeArg] = process.argv.slice(2)
if (strategyArg === 'series') {
// Series mode: per-append render times for one strategy/size, as JSON.
// Usage: npx tsx scripts/bench-streaming-md.tsx series <strategy> <blocks>
const [, seriesStrategy, seriesSize] = process.argv.slice(2)
const size = Number(seriesSize)
const updates = makeUpdates(makeBlocks(size, `${seriesStrategy}${size}`))
const { elapsed, times } = bench(
seriesStrategy!,
updates,
STRATEGIES[seriesStrategy as keyof typeof STRATEGIES],
true
)
console.log(JSON.stringify({ appends: updates.length, elapsed, times }))
} else if (strategyArg) {
// Child mode: run one strategy/size and print elapsed ms as JSON.
const size = Number(sizeArg)
const updates = makeUpdates(makeBlocks(size, `${strategyArg}${size}`))
const { elapsed } = bench(strategyArg, updates, STRATEGIES[strategyArg as keyof typeof STRATEGIES])
console.log(JSON.stringify({ appends: updates.length, elapsed }))
} else {
// Orchestrator mode: one child process per strategy/size.
const sizes = [32, 128, 512]
const run = (strategy: string, size: number): { appends: number; elapsed: number } => {
const out = execFileSync('npx', ['tsx', 'scripts/bench-streaming-md.tsx', strategy, String(size)], {
encoding: 'utf8',
env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=8192' },
timeout: 3_600_000
})
return JSON.parse(out.trim().split('\n').at(-1)!)
}
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${ms.toFixed(1)} ms`)
console.log(
'| Blocks | Append calls | naive (full Md) | monolithic prefix | per-block (new) | new vs naive | new vs monolithic |'
)
console.log(
'|--------|--------------|-----------------|-------------------|-----------------|--------------|-------------------|'
)
for (const size of sizes) {
const naive = run('naive', size)
const mono = run('monolithic', size)
const perBlock = run('per-block', size)
console.log(
`| ${size} | ${perBlock.appends} | ${fmt(naive.elapsed)} | ${fmt(mono.elapsed)} | ${fmt(perBlock.elapsed)} | ${(
naive.elapsed / perBlock.elapsed
).toFixed(1)}x | ${(mono.elapsed / perBlock.elapsed).toFixed(1)}x |`
)
}
}
+244
View File
@@ -0,0 +1,244 @@
/**
* Billing/Subscription TUI fixture harness — renders any single overlay STATE
* live in the terminal so it can be screenshotted (tmux) and UX-reviewed.
*
* This is a DEV/REVIEW tool, not shipped behaviour. It bypasses the gateway and
* mounts the real Ink overlay components directly with a hand-built state object,
* exactly the way the vitest render tests do — so what you see is pixel-identical
* to what `/subscription` and `/topup` draw at runtime.
*
* Usage:
* npx tsx scripts/billing-fixtures.tsx <fixture-name>
* npx tsx scripts/billing-fixtures.tsx --list
*
* Drive a specific screen of a fixture with SCREEN=<screen>, e.g.:
* SCREEN=confirm npx tsx scripts/billing-fixtures.tsx sub-free
* SCREEN=handoff npx tsx scripts/billing-fixtures.tsx sub-mid
*
* The selection cursor can be moved with ↑/↓ once it's live (the components own
* their own useInput); Esc/Enter behave as in production. Ctrl-C to exit.
*/
import { render } from '@hermes/ink'
import React from 'react'
import type { BillingOverlayState, SubscriptionOverlayState, SubscriptionScreen } from '../src/app/interfaces.js'
import { BillingOverlay } from '../src/components/billingOverlay.js'
import { SubscriptionOverlay } from '../src/components/subscriptionOverlay.js'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from '../src/gatewayTypes.js'
import { DEFAULT_THEME } from '../src/theme.js'
const t = DEFAULT_THEME
// ── helpers ──────────────────────────────────────────────────────────
const tier = (o: Partial<SubscriptionTierOption> = {}): SubscriptionTierOption => ({
tier_id: 'free',
name: 'Free',
tier_order: 0,
dollars_per_month_display: '$0',
monthly_credits: '0',
is_current: false,
is_enabled: true,
...o
})
// Mirrors the live portal catalog so fixtures don't drift; the real overlay
// reads tiers from GET /api/billing/subscription, never from here.
const TIERS = {
free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }),
plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }),
super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }),
ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' })
}
const tierList = (currentId?: string): SubscriptionTierOption[] =>
Object.values(TIERS).map(x => ({ ...x, is_current: x.tier_id === currentId }))
const subState = (o: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
can_change_plan: true,
org_name: 'Acme Inc',
org_id: 'org_acme',
role: 'OWNER',
context: 'personal',
current: null,
tiers: tierList(),
portal_url: 'https://portal.nousresearch.com/billing',
...o
})
const cur = (o: Record<string, unknown> = {}) => ({
tier_id: 'plus',
tier_name: 'Plus',
monthly_credits: '1000',
credits_remaining: '420',
cycle_ends_at: '2026-07-01',
pending_downgrade_tier_name: null,
pending_downgrade_at: null,
cancel_at_period_end: false,
cancellation_effective_at: null,
...o
})
const subCtx: SubscriptionOverlayState['ctx'] = {
openManageLink: () => Promise.resolve(true),
refreshState: () => Promise.resolve(null),
sys: () => {}
}
const sub = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pendingTargetTierId: string | null = null): SubscriptionOverlayState => ({
ctx: subCtx,
screen,
state: s,
pendingTargetTierId
})
// ── billing/topup fixtures ───────────────────────────────────────────
const billState = (o: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
ok: true,
logged_in: true,
is_admin: true,
cli_billing_enabled: true,
can_charge: true,
card: { brand: 'Visa', last4: '4242', masked: 'Visa •••• 4242' },
balance_display: '$12.00',
balance_usd: '12.00',
min_usd: '5',
max_usd: '500',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$20',
limit_usd: '20',
spent_display: '$8.00',
spent_this_month_usd: '8'
},
auto_reload: { enabled: false, reload_to_display: '$25', reload_to_usd: '25', threshold_display: '$5', threshold_usd: '5' },
org_name: 'Acme Inc',
role: 'OWNER',
portal_url: 'https://portal.nousresearch.com/billing',
charge_presets: ['10', '25', '50', '100'],
charge_presets_display: ['$10', '$25', '$50', '$100'],
...o
})
const billCtx = {
applyAutoReload: () => Promise.resolve(true),
charge: () => Promise.resolve('submitted' as const),
openPortal: () => {},
requestRemoteSpending: () => Promise.resolve(true),
sys: () => {},
validate: (raw: string) => ({ amount: raw })
}
const bill = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview'): BillingOverlayState => ({
ctx: billCtx,
pendingCharge: screen === 'confirm' || screen === 'stepup' ? { amount: '100' } : null,
screen,
state: s
})
// ── fixture registry ─────────────────────────────────────────────────
type Fixture = { desc: string; node: React.ReactElement }
const subEl = (s: SubscriptionStateResponse, screen: SubscriptionScreen = 'overview', pending: string | null = null) =>
React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch: () => {}, overlay: sub(s, screen, pending), t })
const billEl = (s: BillingStateResponse, screen: BillingOverlayState['screen'] = 'overview') =>
React.createElement(BillingOverlay, { onClose: () => {}, onPatch: () => {}, overlay: bill(s, screen), t })
const FIXTURES: Record<string, Fixture> = {
// /subscription — overview states
'sub-free': {
desc: 'Free / no sub — upgradeable (primary conversion state)',
node: subEl(subState({ current: null }))
},
'sub-mid': {
desc: 'Subscriber mid-tier (Plus) — usage bar + up/downgrade targets',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }))
},
'sub-top': {
desc: 'Subscriber top-tier (Ultra) — "on the top plan"',
node: subEl(subState({ current: cur({ tier_id: 'ultra', tier_name: 'Ultra', monthly_credits: '7000', credits_remaining: '5000' }), tiers: tierList('ultra') }))
},
'sub-not-admin': {
desc: 'Member (not admin/owner) — read-only, no tier picker',
node: subEl(subState({ is_admin: false, can_change_plan: false, role: 'MEMBER', current: cur(), tiers: tierList('plus') }))
},
'sub-downgrade': {
desc: 'Downgrade scheduled — pending-switch banner',
node: subEl(subState({ current: cur({ pending_downgrade_tier_name: 'Plus', pending_downgrade_at: '2026-07-15' }), tiers: tierList('super') }))
},
'sub-cancel': {
desc: 'Cancellation scheduled — stays active until effective date',
node: subEl(subState({ current: cur({ cancel_at_period_end: true, cancellation_effective_at: '2026-07-01' }), tiers: tierList('plus') }))
},
'sub-team': {
desc: 'Team org context — shared credits, redirect to /topup',
node: subEl(subState({ context: 'team', current: null, org_name: 'Acme Engineering' }))
},
// /subscription — non-overview screens
'sub-confirm': {
desc: 'Confirm plan change (deep-link, no in-terminal charge)',
node: subEl(subState({ current: cur(), tiers: tierList('plus') }), 'confirm', 'super')
},
'sub-confirm-new': {
desc: 'Confirm first subscription (free → paid)',
node: subEl(subState({ current: null }), 'confirm', 'plus')
},
'sub-handoff': {
desc: 'Handoff transient — opening subscription page in browser',
node: subEl(subState({ current: cur() }), 'handoff')
},
// /topup (renamed /billing)
'topup-overview': {
desc: '/topup overview — admin, card on file, full menu',
node: billEl(billState())
},
'topup-no-card': {
desc: '/topup overview — admin, NO saved card (card hint)',
node: billEl(billState({ card: null }))
},
'topup-not-admin': {
desc: '/topup overview — member, read-only',
node: billEl(billState({ is_admin: false }))
},
'topup-disabled': {
desc: '/topup overview — remote spending OFF for org',
node: billEl(billState({ cli_billing_enabled: false }))
},
'topup-buy': {
desc: '/topup buy screen — presets',
node: billEl(billState(), 'buy')
},
'topup-stepup': {
desc: '/topup step-up — "Allow Remote Spending" (resumable, holds $100 buy)',
node: billEl(billState(), 'stepup')
}
}
// ── driver ───────────────────────────────────────────────────────────
const arg = process.argv[2]
if (!arg || arg === '--list' || arg === '-l') {
const names = Object.keys(FIXTURES)
process.stdout.write('Billing/Subscription TUI fixtures:\n\n')
for (const name of names) {
process.stdout.write(` ${name.padEnd(18)} ${FIXTURES[name]!.desc}\n`)
}
process.stdout.write(`\n ${names.length} fixtures. Run: npx tsx scripts/billing-fixtures.tsx <name>\n`)
process.exit(0)
}
const fixture = FIXTURES[arg]
if (!fixture) {
process.stderr.write(`Unknown fixture: ${arg}\nRun with --list to see all.\n`)
process.exit(1)
}
render(fixture.node)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
// Bundles src/entry.tsx into a single self-contained dist/entry.js.
// No runtime node_modules needed.
import { build } from 'esbuild'
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const out = resolve(root, 'dist/entry.js')
// `react-devtools-core` is only imported when DEV=true at runtime (Ink dev
// mode). Stub it out so the bundle doesn't carry the dep.
const stubDevtools = {
name: 'stub-react-devtools-core',
setup(b) {
b.onResolve({ filter: /^react-devtools-core$/ }, args => ({
path: args.path,
namespace: 'stub-devtools'
}))
b.onLoad({ filter: /.*/, namespace: 'stub-devtools' }, () => ({
contents: 'export default { initialize() {}, connectToDevTools() {} }',
loader: 'js'
}))
}
}
await build({
entryPoints: [resolve(root, 'src/entry.tsx')],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: out,
jsx: 'automatic',
jsxImportSource: 'react',
// Skip the prebuilt @hermes/ink bundle and inline the source instead:
// (1) esbuild's `__esm` helper does not await nested async init, so the
// prebuilt bundle's lazy `render` would never resolve when nested in
// this top-level Promise.all; (2) bundling from source also lets us
// keep `ink-text-input` and the upstream `ink` graph OUT of the
// bundle entirely — re-exporting them from entry-exports created a
// circular async chain that hung the TUI at startup with only ANSI
// reset bytes on screen (#31227).
alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') },
plugins: [stubDevtools],
// Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles
// don't get a `require` binding automatically, so we inject one.
banner: {
js: "import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);"
},
logLevel: 'info'
})
// esbuild preserves the shebang from src/entry.tsx into the bundle, but Nix's
// patchShebangs phase mangles `/usr/bin/env -S node --foo --bar` (it strips
// the `node` token, leaving a broken interpreter). The hermes_cli launcher
// always invokes this file as `node dist/entry.js` anyway, so the shebang is
// redundant — strip it.
const body = readFileSync(out, 'utf8')
if (body.startsWith('#!')) {
writeFileSync(out, body.slice(body.indexOf('\n') + 1))
}
console.log(`built ${out}`)
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/* global Buffer, console, process, setImmediate */
import inspector from 'node:inspector'
import { performance } from 'node:perf_hooks'
import React from 'react'
import { render } from '@hermes/ink'
import { AppLayout } from '../src/components/appLayout.tsx'
import { resetOverlayState } from '../src/app/overlayStore.ts'
import { resetTurnState } from '../src/app/turnStore.ts'
import { resetUiState } from '../src/app/uiStore.ts'
const session = new inspector.Session()
session.connect()
const post = (method, params = {}) => new Promise((resolve, reject) => {
session.post(method, params, (err, result) => err ? reject(err) : resolve(result))
})
const historySize = Number(process.env.HISTORY || 500)
const mountedRows = Number(process.env.MOUNTED || 120)
class Sink {
columns = Number(process.env.COLS || 120)
rows = Number(process.env.ROWS || 42)
isTTY = true
bytes = 0
writes = 0
listeners = new Map()
write(chunk) {
this.bytes += Buffer.byteLength(String(chunk ?? ''))
this.writes++
return true
}
on(event, fn) { this.listeners.set(event, fn); return this }
off(event) { this.listeners.delete(event); return this }
once(event, fn) { this.listeners.set(event, fn); return this }
removeListener(event) { this.listeners.delete(event); return this }
}
const theme = {
brand: { prompt: '' },
color: {
amber: '#d19a66', bronze: '#8b6f47', dim: '#6b7280', error: '#ff5555', gold: '#ffd166', label: '#61afef',
ok: '#98c379', warn: '#e5c07b', cornsilk: '#fff8dc', prompt: '#c678dd', shellDollar: '#98c379',
statusCritical: '#ff5555', statusBad: '#e06c75', statusWarn: '#e5c07b', statusGood: '#98c379',
selectionBg: '#44475a'
}
}
const noop = () => {}
const historyItems = [
{ kind: 'intro', role: 'system', text: '', info: { model: 'test', tools: {}, skills: {}, version: 'test' } },
...Array.from({ length: historySize }, (_, i) => ({
role: i % 5 === 0 ? 'user' : 'assistant',
text: `message ${i}\n${'lorem ipsum '.repeat(80)}`
}))
]
const scrollRef = { current: {
getScrollTop: () => 0,
getPendingDelta: () => 0,
getScrollHeight: () => historySize * 4,
getViewportHeight: () => 30,
getViewportTop: () => 0,
isSticky: () => true,
subscribe: () => () => {},
scrollBy: noop,
scrollTo: noop,
scrollToBottom: noop,
setClampBounds: noop,
getLastManualScrollAt: () => 0
} }
const baseProps = streamingText => ({
actions: { answerApproval: noop, answerClarify: noop, answerSecret: noop, answerSudo: noop, onModelSelect: noop, resumeById: noop, setStickyPrompt: noop },
composer: { cols: 120, compIdx: 0, completions: [], empty: false, handleTextPaste: () => null, input: '', inputBuf: [], pagerPageSize: 10, queueEditIdx: null, queuedDisplay: [], submit: noop, updateInput: noop },
mouseTracking: false,
progress: {
activity: [], outcome: '', reasoning: streamingText, reasoningActive: true, reasoningStreaming: true,
reasoningTokens: Math.ceil(streamingText.length / 4), showProgressArea: true, showStreamingArea: true,
streamPendingTools: [], streamSegments: [], streaming: streamingText, subagents: [], toolTokens: 0, tools: [], turnTrail: [], todos: []
},
status: { cwdLabel: '~/repo', goodVibesTick: 0, sessionStartedAt: Date.now(), showStickyPrompt: false, statusColor: theme.color.ok, stickyPrompt: '', turnStartedAt: Date.now(), voiceLabel: 'voice off' },
transcript: {
historyItems,
scrollRef,
virtualHistory: { bottomSpacer: 0, end: historyItems.length, measureRef: () => noop, offsets: historyItems.map((_, i) => i * 4), start: Math.max(0, historyItems.length - mountedRows), topSpacer: 0 },
virtualRows: historyItems.map((msg, index) => ({ index, key: `m${index}`, msg }))
}
})
async function main() {
resetUiState()
resetTurnState()
resetOverlayState()
const stdout = new Sink()
const stdin = { isTTY: true, setRawMode: noop, on: noop, off: noop, resume: noop, pause: noop }
const text = Array.from({ length: Number(process.env.LINES || 1200) }, (_, i) => `stream line ${i} ${'x'.repeat(90)}`).join('\n')
const inst = render(React.createElement(AppLayout, baseProps('')), { stdout, stdin, stderr: stdout, debug: false, exitOnCtrlC: false })
await post('Profiler.enable')
await post('HeapProfiler.enable')
await post('Profiler.start')
const startMem = process.memoryUsage()
const t0 = performance.now()
const iterations = Number(process.env.ITERS || 40)
for (let i = 1; i <= iterations; i++) {
const prefix = text.slice(0, Math.floor(text.length * i / iterations))
inst.rerender(React.createElement(AppLayout, baseProps(prefix)))
await new Promise(r => setImmediate(r))
}
const elapsed = performance.now() - t0
const prof = await post('Profiler.stop')
const endMem = process.memoryUsage()
await post('HeapProfiler.collectGarbage')
const afterGc = process.memoryUsage()
inst.unmount()
session.disconnect()
console.log(JSON.stringify({ elapsedMs: Math.round(elapsed), stdoutBytes: stdout.bytes, stdoutWrites: stdout.writes, startMem, endMem, afterGc, profileNodes: prof.profile.nodes.length }, null, 2))
}
main().catch(err => { console.error(err); process.exit(1) })
+11
View File
@@ -0,0 +1,11 @@
// Shared output location for the visual harness. Hardcoded '/tmp/...' paths
// resolve to a drive-root like C:\tmp on native Windows (and fail when the
// directory doesn't exist) — os.tmpdir() is the platform-neutral answer.
// Both render.tsx and shot.mjs derive the same directory from here;
// HERMES_TUI_VISUAL_DIR overrides it for CI or side-by-side runs.
import { tmpdir } from 'os'
import { join } from 'path'
export function visualOutDir() {
return process.env.HERMES_TUI_VISUAL_DIR || join(tmpdir(), 'hermes-tui-visual')
}
+319
View File
@@ -0,0 +1,319 @@
/* Visual self-verification tool: `npm run visual` renders real TUI surfaces
* across theme x background scenes to <tmpdir>/hermes-tui-visual/tui-visual.html,
* then shot.mjs screenshots it to tui-visual.png for eyeball + agent review.
*
* Original note: : render real TUI surfaces with ANSI colors intact,
* convert to HTML on the actual background, and screenshot in a browser. */
process.env.FORCE_COLOR = '3'
process.env.COLORTERM = 'truecolor'
import '../../src/lib/forceTruecolor.js'
import { mkdirSync, writeFileSync } from 'fs'
import { join } from 'path'
import { PassThrough } from 'stream'
import { visualOutDir } from './paths.mjs'
import { Box, renderSync, Text } from '@hermes/ink'
import React, { type ReactElement } from 'react'
import { GatewayProvider } from '../../src/app/gatewayContext.js'
import { patchOverlayState, resetOverlayState } from '../../src/app/overlayStore.js'
import { patchUiState, resetUiState } from '../../src/app/uiStore.js'
import { FloatingOverlays } from '../../src/components/appOverlays.js'
import { Banner, SessionPanel } from '../../src/components/branding.js'
import { fromSkin, type Theme } from '../../src/theme.js'
import type { SessionInfo } from '../../src/types.js'
const noop = () => {}
const pending = () => new Promise<never>(() => {})
const fakeGateway = { gw: { notify: noop, off: noop, on: noop, request: pending }, rpc: pending } as any
const SLATE = {
banner_border: '#4169e1',
banner_title: '#7eb8f6',
banner_accent: '#8EA8FF',
banner_dim: '#4b5563',
banner_text: '#c9d1d9',
ui_accent: '#7eb8f6',
ui_label: '#8EA8FF',
ui_ok: '#63D0A6',
ui_error: '#F7A072',
ui_warn: '#e6a855',
prompt: '#c9d1d9',
session_label: '#7eb8f6',
session_border: '#545E6B',
status_bar_bg: '#151C2F',
status_bar_text: '#C9D1D9'
}
// The regenerated slate light_colors block from hermes_cli/skin_engine.py
// (relight recipe: vivid hue-preserved accents, airy capped-saturation text,
// darker calm dims).
const info: SessionInfo = {
cwd: '/Users/brooklyn/www/hermes-agent',
mcp_servers: [{ connected: true, name: 'figma', tools: 12, transport: 'sse' }],
model: 'claude-opus-4.8-fast',
skills: {
devops: ['docker', 'kubernetes', 'terraform'],
github: ['pr-review', 'issue-triage'],
productivity: ['powerpoint', 'excel', 'notion-sync']
},
tools: {
browser: ['browser_back', 'browser_click', 'browser_console', 'browser_get_images'],
clarify: ['clarify'],
code_execution: ['execute_code'],
cronjob: ['cronjob'],
delegation: ['delegate_task'],
file: ['patch', 'read_file', 'search_files', 'write_file']
},
update_behind: 1,
version: '3.2.1'
}
const completions = [
{ display: '/new', meta: 'Start a new session (fresh session ID + history)', text: '/new' },
{ display: '/reset', meta: 'Start a new session (alias for /new)', text: '/reset' },
{ display: '/clear', meta: 'Clear screen and start a new session', text: '/clear' },
{ display: '/redraw', meta: 'Force a full UI repaint', text: '/redraw' },
{ display: '/history', meta: 'Show conversation history', text: '/history' }
]
function renderAnsi(node: ReactElement, columns: number): string {
const stdout = new PassThrough()
const stdin = new PassThrough()
const stderr = new PassThrough()
let output = ''
;(process.stdout as unknown as { columns: number }).columns = columns
Object.assign(stdout, { columns, isTTY: false, rows: 60 })
Object.assign(stdin, {
isTTY: true,
pause: noop,
ref: noop,
resume: noop,
setEncoding: noop,
setRawMode: noop,
unref: noop
})
Object.assign(stderr, { isTTY: false })
stdout.on('data', chunk => {
output += chunk.toString()
})
const instance = renderSync(
<GatewayProvider value={fakeGateway}>
<Box flexDirection="column" width={columns}>{node}</Box>
</GatewayProvider>,
{
exitOnCtrlC: false,
patchConsole: false,
stderr: stderr as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
stdout: stdout as unknown as NodeJS.WriteStream
}
)
instance.unmount()
instance.cleanup()
return output
}
// ── ANSI → HTML (handles ink's SGR set: 38;2/48;2 truecolor, named resets, bold/dim/italic/inverse) ──
const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
function ansiToHtml(raw: string, defaultFg: string, defaultBg: string): string {
let fg = defaultFg
let bg = defaultBg
let bold = false
let dim = false
let italic = false
let inverse = false
let html = ''
const openSpan = () => {
const f = inverse ? bg : fg
const b = inverse ? fg : bg
const styles = [`color:${f}`]
if (b !== defaultBg || inverse) {
styles.push(`background-color:${b}`)
}
if (bold) {
styles.push('font-weight:bold')
}
if (dim) {
styles.push('opacity:0.55')
}
if (italic) {
styles.push('font-style:italic')
}
return `<span style="${styles.join(';')}">`
}
// eslint-disable-next-line no-control-regex
const parts = raw.split(/(\x1b\[[0-9;]*m)/)
html += openSpan()
for (const part of parts) {
// eslint-disable-next-line no-control-regex
const m = /^\x1b\[([0-9;]*)m$/.exec(part)
if (!m) {
// Drop non-SGR escapes (cursor moves etc.) — renderSync output for a
// static frame is line-oriented, so this is safe for inspection.
// eslint-disable-next-line no-control-regex
html += escapeHtml(part.replace(/\x1b\[[^m]*[A-Za-z]/g, ''))
continue
}
const codes = (m[1] || '0').split(';').map(Number)
for (let i = 0; i < codes.length; i++) {
const c = codes[i]!
if (c === 0) {
fg = defaultFg
bg = defaultBg
bold = dim = italic = inverse = false
} else if (c === 1) {bold = true}
else if (c === 2) {dim = true}
else if (c === 3) {italic = true}
else if (c === 7) {inverse = true}
else if (c === 22) { bold = false; dim = false }
else if (c === 23) {italic = false}
else if (c === 27) {inverse = false}
else if (c === 39) {fg = defaultFg}
else if (c === 49) {bg = defaultBg}
else if (c === 38 && codes[i + 1] === 2) {
fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
i += 4
} else if (c === 48 && codes[i + 1] === 2) {
bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`
i += 4
} else if (c === 38 && codes[i + 1] === 5) {
fg = `var(--a${codes[i + 2]}, #888)`
i += 2
} else if (c === 48 && codes[i + 1] === 5) {
bg = `var(--a${codes[i + 2]}, #888)`
i += 2
}
}
html += `</span>${openSpan()}`
}
return html + '</span>'
}
// ── Scenes ──
interface Scene {
bg: string
fg: string
name: string
theme: Theme
}
const setup = (bgHex: string) => {
process.env.HERMES_TUI_BACKGROUND = bgHex
resetOverlayState()
resetUiState()
}
const scenes: Scene[] = []
const addScene = (name: string, bgHex: string, skin: Record<string, string>) => {
setup(bgHex)
const theme = fromSkin(skin, {})
scenes.push({ bg: bgHex, fg: theme.color.text, name, theme })
}
addScene('default · dark terminal', '#101014', {})
addScene('default · light terminal (Cursor)', '#ffffff', {})
addScene('slate · dark terminal', '#101014', SLATE)
addScene('slate · light terminal (raw palette + display shim)', '#ffffff', SLATE)
let page = `<!doctype html><meta charset="utf-8"><body style="margin:0;background:#666;font:13px/1.35 Menlo,monospace"><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:16px">`
for (const scene of scenes) {
setup(scene.bg)
patchUiState({ sid: 'd2a6ecf8', theme: scene.theme })
const intro = renderAnsi(
<Box flexDirection="column">
<Banner maxWidth={86} t={scene.theme} />
<SessionPanel info={info} maxWidth={86} sid="d2a6ecf8" t={scene.theme} />
</Box>,
88
)
patchOverlayState({})
const comps = renderAnsi(
<Box flexDirection="column" height={10} position="relative" width={88}>
<Box flexGrow={1} />
<FloatingOverlays
cols={88}
compIdx={0}
completions={completions}
onActiveSessionClose={pending}
onActiveSessionSelect={noop}
onModelSelect={noop}
onNewLiveSession={noop}
onNewPromptSession={noop}
onResumeSelect={noop}
pagerPageSize={8}
/>
</Box>,
88
)
const statusLine = renderAnsi(
<Box flexDirection="column">
<Text>
<Text color={scene.theme.color.statusGood}> ready </Text>
<Text color={scene.theme.color.muted}>| opus 4.8 fast | 4s | voice off</Text>
</Text>
<Text>
<Text color={scene.theme.color.muted}>{scene.theme.brand.prompt} </Text>
<Text backgroundColor={scene.theme.color.muted} color={scene.bg}>T</Text>
<Text color={scene.theme.color.muted}>ry &quot;fix the lint errors&quot;</Text>
</Text>
</Box>,
88
)
page += `<div style="background:${scene.bg};color:${scene.fg};padding:14px;border-radius:6px">`
page += `<div style="font:bold 12px sans-serif;opacity:.6;margin-bottom:8px;color:${scene.fg}">${scene.name}</div>`
page += `<pre style="margin:0;white-space:pre">${ansiToHtml(intro, scene.fg, scene.bg)}</pre>`
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(comps, scene.fg, scene.bg)}</pre>`
page += `<pre style="margin:8px 0 0;white-space:pre">${ansiToHtml(statusLine, scene.fg, scene.bg)}</pre>`
page += `</div>`
}
page += '</div></body>'
const outDir = visualOutDir()
mkdirSync(outDir, { recursive: true })
const outFile = join(outDir, 'tui-visual.html')
writeFileSync(outFile, page)
console.log(`wrote ${outFile}`)
process.exit(0)
+47
View File
@@ -0,0 +1,47 @@
// Zero-dependency launcher for the visual harness (`npm run visual`).
//
// - Sets FORCE_COLOR/COLORTERM itself instead of POSIX `VAR=x cmd` shell
// assignments (which break under the Windows npm command shell) — no
// cross-env needed.
// - Resolves electron from the install tree (the desktop workspace already
// ships it; a root `npm install` hoists it) instead of declaring a second
// copy as a ui-tui dependency. ELECTRON_BIN overrides for exotic setups.
import { spawnSync } from 'child_process'
import { createRequire } from 'module'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const here = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const run = (bin, args, env = {}) => {
const { status } = spawnSync(bin, args, { env: { ...process.env, ...env }, stdio: 'inherit' })
if (status !== 0) {
process.exit(status ?? 1)
}
}
// 1. Render the scene sheet (tsx is a ui-tui devDependency).
run(process.execPath, [require.resolve('tsx/cli'), join(here, 'render.tsx')], {
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
})
// 2. Screenshot it with electron, borrowed from the workspace that owns it.
let electronBin = process.env.ELECTRON_BIN
if (!electronBin) {
try {
// In plain Node, `require('electron')` evaluates to the binary path.
electronBin = require('electron')
} catch {
console.error(
'electron is not installed in this tree — the visual harness borrows it from the\n' +
'desktop workspace. Run `npm install` at the repo root, or point ELECTRON_BIN at a binary.'
)
process.exit(1)
}
}
run(electronBin, [join(here, 'shot.mjs')])
+29
View File
@@ -0,0 +1,29 @@
// Screenshot the render.tsx output with the workspace's Electron (offscreen).
import { app, BrowserWindow } from 'electron'
import { writeFileSync } from 'fs'
import { join } from 'path'
import { visualOutDir } from './paths.mjs'
app.disableHardwareAcceleration()
app.whenReady().then(async () => {
const win = new BrowserWindow({
height: 2100,
show: false,
webPreferences: { offscreen: true },
width: 1500
})
const outDir = visualOutDir()
await win.loadFile(join(outDir, 'tui-visual.html'))
await new Promise(r => setTimeout(r, 700))
const image = await win.webContents.capturePage()
const outFile = join(outDir, 'tui-visual.png')
writeFileSync(outFile, image.toPNG())
console.log(`wrote ${outFile}`)
app.quit()
})