Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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()
|
||||
@@ -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 |`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}`)
|
||||
@@ -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) })
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
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 "fix the lint errors"</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)
|
||||
@@ -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')])
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user