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

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import type { BillingBlock } from '@hermes/shared/billing'
import { describe, expect, it } from 'vitest'
import { billingDialogCopy } from './billingDialog.js'
function makeBlock(overrides: Partial<BillingBlock> = {}): BillingBlock {
return {
billing_url: 'https://openrouter.ai/settings/credits',
is_nous: false,
message: 'out of credits',
model: 'x',
provider: 'openrouter',
provider_label: 'OpenRouter',
...overrides
}
}
describe('billingDialogCopy', () => {
it('routes Nous to the /topup flow', () => {
const copy = billingDialogCopy(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' }))
expect(copy.title).toContain('Nous')
expect(copy.confirmLabel).toBe('Top up')
expect(copy.cancelLabel).toBe('Dismiss')
})
it('offers to open a third-party provider billing page', () => {
const copy = billingDialogCopy(makeBlock())
expect(copy.title).toContain('OpenRouter')
expect(copy.confirmLabel).toBe('Open billing page')
})
it('falls back to switching providers when there is no URL', () => {
const copy = billingDialogCopy(makeBlock({ billing_url: null, provider_label: 'DeepSeek' }))
expect(copy.title).toContain('DeepSeek')
expect(copy.confirmLabel).toBe('Switch provider')
})
})
+36
View File
@@ -0,0 +1,36 @@
import type { BillingBlock } from '@hermes/shared/billing'
export interface BillingDialogCopy {
cancelLabel: string
confirmLabel: string
detail: string
title: string
}
/**
* Copy for the out-of-credits confirm dialog (the TUI's billing wall). The
* dialog is the actionable layer — the full provider guidance already lands in
* the transcript — so `detail` stays to one concise, non-truncating line and the
* confirm button carries the recovery: Nous → `/topup`, other providers → their
* billing page (or `/model` to switch when we have no URL). Pure + exported so
* the wording is unit-tested without driving the gateway.
*/
export function billingDialogCopy(block: BillingBlock): BillingDialogCopy {
if (block.is_nous) {
return {
cancelLabel: 'Dismiss',
confirmLabel: 'Top up',
detail: 'Your Nous credit balance is exhausted — top up to keep going.',
title: 'Out of Nous credits'
}
}
const label = block.provider_label || 'your provider'
return {
cancelLabel: 'Dismiss',
confirmLabel: block.billing_url ? 'Open billing page' : 'Switch provider',
detail: `${label} reports your credits or billing are exhausted.`,
title: `Out of credits · ${label}`
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Chart primitives — pure string builders (no React), THE charting layer for
* widget apps and demos alike. Everything returns plain strings the caller
* colors with theme tones; everything auto-scales to the series' min/max.
*/
const BLOCKS = '▁▂▃▄▅▆▇█'
const normalize = (series: number[], window: number): { min: number; range: number; window: number[] } => {
const view = series.slice(-Math.max(1, window))
const min = Math.min(...view)
return { min, range: Math.max(...view) - min || 1, window: view }
}
/** One-row sparkline: `▂▃▅▇█▆…`, last `width` samples. ALWAYS exactly
* `width` cells — short series pad-left so the card never resizes while
* history warms up (latest sample stays pinned to the right edge). */
export function sparkline(series: number[], width = series.length): string {
if (!series.length) {
return ' '.repeat(Math.max(0, width))
}
const { min, range, window } = normalize(series, width)
return window
.map(v => BLOCKS[Math.min(BLOCKS.length - 1, Math.floor(((v - min) / range) * BLOCKS.length))])
.join('')
.padStart(width)
}
/**
* Multi-row column chart, top line first — the streams-demo panel chart.
* Each column resolves to `rows * 8` vertical levels (full blocks below the
* value, a partial eighth-block at it), so taller cells genuinely gain
* resolution.
*/
export function sparkRows(series: number[], width: number, rows: number): string[] {
if (!series.length) {
return Array.from({ length: rows }, () => ' '.repeat(width))
}
const { min, range, window } = normalize(series, width)
const levels = window.map(v => Math.max(1, Math.round(((v - min) / range) * rows * 8)))
return Array.from({ length: rows }, (_, lineIdx) => {
const rowFromBottom = rows - 1 - lineIdx
return levels
.map(level => {
const filled = Math.min(8, Math.max(0, level - rowFromBottom * 8))
return filled === 0 ? ' ' : BLOCKS[filled - 1]
})
.join('')
.padStart(width) // warm-up pads left: chart grows from the right, card never resizes
})
}
/** Horizontal fill gauge: `█████░░░` for a 0..1 ratio. */
export function gauge(ratio: number, width: number): string {
const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
}
/** Horizontal bar chart: one `███▌`-style bar per value, scaled to the max,
* each padded to exactly `width` (stable card sizing). Eighth-block tips
* keep adjacent values distinguishable. */
export function hbars(values: number[], width: number): string[] {
const max = Math.max(...values, 0) || 1
return values.map(v => {
const cells = (Math.min(max, Math.max(0, v)) / max) * width
const full = Math.floor(cells)
const rest = Math.round((cells - full) * 8)
return ('█'.repeat(full) + (rest > 0 ? '▏▎▍▌▋▊▉█'[rest - 1] : '')).padEnd(width)
})
}
+48
View File
@@ -0,0 +1,48 @@
export class CircularBuffer<T> {
private buf: T[]
private head = 0
private len = 0
constructor(private capacity: number) {
if (!Number.isInteger(capacity) || capacity <= 0) {
throw new RangeError(`CircularBuffer capacity must be a positive integer, got ${capacity}`)
}
this.buf = new Array<T>(capacity)
}
push(item: T) {
this.buf[this.head] = item
this.head = (this.head + 1) % this.capacity
if (this.len < this.capacity) {
this.len++
}
}
tail(n = this.len): T[] {
const take = Math.min(Math.max(0, n), this.len)
const start = this.len < this.capacity ? 0 : this.head
const out: T[] = new Array<T>(take)
for (let i = 0; i < take; i++) {
out[i] = this.buf[(start + this.len - take + i) % this.capacity]!
}
return out
}
drain(): T[] {
const out = this.tail()
this.clear()
return out
}
clear() {
this.buf = new Array<T>(this.capacity)
this.head = 0
this.len = 0
}
}
+202
View File
@@ -0,0 +1,202 @@
import { execFile, spawn } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
const CLIPBOARD_MAX_BUFFER = 4 * 1024 * 1024
// PowerShell read: base64-encode the clipboard content to avoid ANSI codepage
// corruption (same problem as the write path — see comment at line 94).
const POWERSHELL_READ_ARGS = [
'-NoProfile',
'-NonInteractive',
'-Command',
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'
] as const
type ClipboardRun = typeof execFileAsync
export function isUsableClipboardText(text: null | string): text is string {
if (!text || !/[^\s]/.test(text)) {
return false
}
if (text.includes('\u0000')) {
return false
}
let suspicious = 0
for (const ch of text) {
const code = ch.charCodeAt(0)
const isControl = code < 0x20 && ch !== '\n' && ch !== '\r' && ch !== '\t'
if (isControl || ch === '\ufffd') {
suspicious += 1
}
}
return suspicious <= Math.max(2, Math.floor(text.length * 0.02))
}
function readClipboardCommands(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv
): Array<{ args: readonly string[]; cmd: string; base64?: boolean }> {
if (platform === 'darwin') {
return [{ cmd: 'pbpaste', args: [] }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: POWERSHELL_READ_ARGS, base64: true }]
}
const attempts: Array<{ args: readonly string[]; cmd: string; base64?: boolean }> = []
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({ cmd: 'powershell.exe', args: POWERSHELL_READ_ARGS, base64: true })
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-paste', args: ['--type', 'text'] })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-out'] })
return attempts
}
/**
* Read plain text from the system clipboard.
*
* Uses native platform tools in fallback order:
* - macOS: pbpaste
* - Windows: PowerShell Get-Clipboard -Raw
* - WSL: powershell.exe Get-Clipboard -Raw
* - Linux Wayland: wl-paste --type text
* - Linux X11: xclip -selection clipboard -out
*/
export async function readClipboardText(
platform: NodeJS.Platform = process.platform,
run: ClipboardRun = execFileAsync,
env: NodeJS.ProcessEnv = process.env
): Promise<string | null> {
for (const attempt of readClipboardCommands(platform, env)) {
try {
const result = await run(attempt.cmd, [...attempt.args], {
encoding: 'utf8',
maxBuffer: CLIPBOARD_MAX_BUFFER,
windowsHide: true
})
if (typeof result.stdout === 'string') {
if (attempt.base64) {
return Buffer.from(result.stdout.trim(), 'base64').toString('utf8')
}
return result.stdout
}
} catch {
// Fall through to the next clipboard backend.
}
}
return null
}
// PowerShell on Windows/WSL decodes piped stdin with the system ANSI code
// page (e.g. CP936), not UTF-8, so $input-based writes mangle CJK/emoji. We
// instead base64-encode the UTF-8 bytes and pass them as a -Command argument,
// decoding with UTF8.GetString — this removes the stdin-encoding variable
// entirely (also immune to BOM injection on redirect). PowerShell entries set
// stdin=false; every other backend reads UTF-8 stdin natively.
type WriteCmd = { args: readonly string[]; cmd: string; stdin: boolean }
function _powershellWriteScript(b64: string): string {
return `Set-Clipboard -Value ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${b64}')))`
}
function writeClipboardCommands(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): WriteCmd[] {
if (platform === 'darwin') {
return [{ cmd: 'pbcopy', args: [], stdin: true }]
}
if (platform === 'win32') {
return [{ cmd: 'powershell', args: ['-NoProfile', '-NonInteractive'], stdin: false }]
}
const attempts: WriteCmd[] = []
if (env.WSL_INTEROP || env.WSL_DISTRO_NAME) {
attempts.push({ cmd: 'powershell.exe', args: ['-NoProfile', '-NonInteractive'], stdin: false })
}
if (env.WAYLAND_DISPLAY) {
attempts.push({ cmd: 'wl-copy', args: ['--type', 'text/plain'], stdin: true })
}
attempts.push({ cmd: 'xclip', args: ['-selection', 'clipboard', '-in'], stdin: true })
attempts.push({ cmd: 'xsel', args: ['--clipboard', '--input'], stdin: true })
return attempts
}
/**
* Write plain text to the system clipboard.
*
* Tries native platform tools in fallback order:
* - macOS: pbcopy
* - Windows: PowerShell Set-Clipboard
* - WSL: powershell.exe Set-Clipboard
* - Linux Wayland: wl-copy --type text/plain
* - Linux X11: xclip -selection clipboard -in
* - Linux X11 alt: xsel --clipboard --input
*
* Returns true if at least one backend succeeded, false otherwise
* (callers should fall back to OSC52 on false).
*/
export async function writeClipboardText(
text: string,
platform: NodeJS.Platform = process.platform,
start: typeof spawn = spawn,
env: NodeJS.ProcessEnv = process.env
): Promise<boolean> {
const candidates = writeClipboardCommands(platform, env)
for (const cmdEntry of candidates) {
try {
const ok = await new Promise<boolean>(resolve => {
if (cmdEntry.stdin) {
const child = start(cmdEntry.cmd, [...cmdEntry.args], {
stdio: ['pipe', 'ignore', 'ignore'],
windowsHide: true
})
child.unref()
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
child.stdin?.end(text)
} else {
const b64 = Buffer.from(text, 'utf8').toString('base64')
const script = _powershellWriteScript(b64)
const child = start(cmdEntry.cmd, [...cmdEntry.args, '-Command', script], {
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true
})
child.unref()
child.once('error', () => resolve(false))
child.once('close', (code: number | null) => resolve(code === 0))
}
})
if (ok) {
return true
}
} catch {
// Fall through to the next clipboard backend.
}
}
return false
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { color, contrastRatio, ensureContrast, mix, parseColor, readableOn, relativeLuminance, toHex } from './color.js'
describe('parseColor', () => {
it('parses hex6, hex3 and rgb() forms', () => {
expect(parseColor('#1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('1a2b3c')).toEqual([0x1a, 0x2b, 0x3c])
expect(parseColor('#abc')).toEqual([0xaa, 0xbb, 0xcc])
expect(parseColor('rgb(220,255,220)')).toEqual([220, 255, 220])
expect(parseColor('rgba(10, 20, 30, 0.5)')).toEqual([10, 20, 30])
})
it('rejects garbage', () => {
expect(parseColor('')).toBeNull()
expect(parseColor('ansi256(245)')).toBeNull()
expect(parseColor('#12345')).toBeNull()
})
})
describe('mix', () => {
it('lerps in sRGB and round-trips through toHex', () => {
expect(mix('#000000', '#ffffff', 0.5)).toBe('#808080')
expect(mix('#ff0000', '#0000ff', 0)).toBe('#ff0000')
expect(mix('#ff0000', '#0000ff', 1)).toBe('#0000ff')
expect(toHex(parseColor(mix('#123456', '#654321', 0.3))!)).toBe(mix('#123456', '#654321', 0.3))
})
it('passes unparseable inputs through unchanged', () => {
expect(mix('ansi256(245)', '#ffffff', 0.5)).toBe('ansi256(245)')
expect(mix('#ff0000', 'nope', 0.5)).toBe('#ff0000')
})
})
describe('contrast', () => {
it('measures WCAG ratios at the anchors', () => {
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 0)
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5)
expect(relativeLuminance('#000000')).toBeCloseTo(0, 5)
})
it('readableOn picks the ink pole', () => {
expect(readableOn('#ffffff')).toBe('#000000')
expect(readableOn('#101014')).toBe('#ffffff')
})
it('ensureContrast lifts failing colors monotonically and leaves passing ones alone', () => {
const pale = '#FFF8DC'
const fixed = ensureContrast(pale, '#ffffff', 3.9)
expect(contrastRatio(fixed, '#ffffff')!).toBeGreaterThanOrEqual(3.9)
expect(ensureContrast('#3D2F13', '#ffffff', 3.9)).toBe('#3D2F13')
expect(ensureContrast('ansi256(245)', '#ffffff', 3.9)).toBe('ansi256(245)')
})
})
describe('color() chain', () => {
it('composes the ladder operations', () => {
const out = color('#F1E6CF').mix('#101014', 0.35).mix('#DD4A3A', 0.18).ensureContrast('#101014', 2.8).hex()
expect(out).toMatch(/^#[0-9a-f]{6}$/)
expect(contrastRatio(out, '#101014')!).toBeGreaterThanOrEqual(2.8)
expect(color('#808080').luminance()).toBeCloseTo(relativeLuminance('#808080')!, 10)
})
})
+325
View File
@@ -0,0 +1,325 @@
/**
* THE color primitive — every color computation in the TUI goes through this
* module (the twin of the desktop app's `src/themes/color.ts`). No component
* or theme code does its own hex parsing or channel math: parse, mix, measure
* and fix colors here, so tone ladders, contrast floors and one-off UI needs
* all share one set of semantics.
*
* Mixing is an sRGB lerp — deliberately, for byte parity with the desktop's
* `color-mix(in srgb, ...)` ladder in styles.css.
*/
export type Rgb = readonly [number, number, number]
const HEX6_RE = /^#?([0-9a-f]{6})$/i
const HEX3_RE = /^#?([0-9a-f]{3})$/i
const RGB_FN_RE = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i
const clampChannel = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
/** Parse `#rgb`, `#rrggbb` or `rgb(r,g,b)` → channels. Null for anything else. */
export function parseColor(input: string): Rgb | null {
const value = input.trim()
let m = HEX6_RE.exec(value)
if (m) {
const n = parseInt(m[1]!, 16)
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
m = HEX3_RE.exec(value)
if (m) {
const [r, g, b] = m[1]!
return [parseInt(r! + r!, 16), parseInt(g! + g!, 16), parseInt(b! + b!, 16)]
}
m = RGB_FN_RE.exec(value)
if (m) {
return [clampChannel(Number(m[1])), clampChannel(Number(m[2])), clampChannel(Number(m[3]))]
}
return null
}
export const toHex = (rgb: Rgb): string => '#' + rgb.map(c => clampChannel(c).toString(16).padStart(2, '0')).join('')
/** sRGB lerp `a → b` by `t` in [0,1]. Unparseable inputs return `a` unchanged. */
export function mix(a: string, b: string, t: number): string {
const pa = parseColor(a)
const pb = parseColor(b)
if (!pa || !pb) {
return a
}
return toHex([pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t])
}
function channelLuminance(value: number): number {
const normalized = value / 255
return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
}
/** WCAG relative luminance in [0,1]. Null when unparseable. */
export function relativeLuminance(color: string): null | number {
const rgb = parseColor(color)
return rgb
? 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2])
: null
}
/** WCAG contrast ratio between two colors (1–21). Null when unparseable. */
export function contrastRatio(a: string, b: string): null | number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
if (la === null || lb === null) {
return null
}
const [hi, lo] = la >= lb ? [la, lb] : [lb, la]
return (hi + 0.05) / (lo + 0.05)
}
/** The readable ink pole for a given background (desktop `readableOn`). */
export function readableOn(bg: string): '#000000' | '#ffffff' {
return (relativeLuminance(bg) ?? 0) > 0.5 ? '#000000' : '#ffffff'
}
/**
* Step-mix `color` toward the readable pole of `bg` until the contrast ratio
* clears `min` (desktop `ensureContrast`). Each step re-mixes from the
* ORIGINAL color so hue decays linearly, not exponentially. Returns the
* original when it already passes or isn't parseable.
*/
export function ensureContrast(color: string, bg: string, min: number): string {
if (relativeLuminance(bg) === null || parseColor(color) === null) {
return color
}
const pole = readableOn(bg)
let current = color
for (let step = 0; step <= 20; step++) {
const ratio = contrastRatio(current, bg)
if (ratio === null || ratio >= min) {
return current
}
current = mix(color, pole, Math.min(1, (step + 1) * 0.05))
}
return current
}
/**
* xterm.js's minimum-contrast algorithm (Color.ts reduce/increaseLuminance),
* ported faithfully: multiplicative 10% channel steps toward the readable
* pole, which preserves channel RATIOS (hue and perceived chroma) far better
* than mixing toward black/white. This is the display shim's lift — the same
* math terminals themselves use, so a palette lifted here matches what hosts
* like VS Code/Cursor produce when their own minimumContrastRatio kicks in.
* Colors already at or above `ratio` pass through byte-identical.
*/
export function liftForContrast(color: string, bg: string, ratio: number): string {
const fg = parseColor(color)
const bgLum = relativeLuminance(bg)
if (!fg || bgLum === null) {
return color
}
// Byte-identical passthrough when the color already clears the ratio —
// the authored value is the design; only failing colors get touched.
if ((contrastRatio(color, bg) ?? 21) >= ratio) {
return color
}
let [r, g, b] = fg
if (bgLum > 0.5) {
// reduceLuminance: darken multiplicatively.
let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
while (cr < ratio && (r > 0 || g > 0 || b > 0)) {
r -= Math.ceil(r * 0.1)
g -= Math.ceil(g * 0.1)
b -= Math.ceil(b * 0.1)
cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
}
} else {
// increaseLuminance: brighten toward white in 10% remaining-headroom steps.
let cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
while (cr < ratio && (r < 255 || g < 255 || b < 255)) {
r = Math.min(255, r + Math.ceil((255 - r) * 0.1))
g = Math.min(255, g + Math.ceil((255 - g) * 0.1))
b = Math.min(255, b + Math.ceil((255 - b) * 0.1))
cr = contrastRatio(toHex([r, g, b]), bg) ?? 21
}
}
return toHex([r, g, b])
}
/** Recede toward the background pole (opposite of `readableOn`). */
export const lighten = (color: string, t: number) => mix(color, '#ffffff', t)
export const darken = (color: string, t: number) => mix(color, '#000000', t)
/** The luminance-weighted gray of a color (its perceptual brightness). */
export function grayOf(color: string): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const gray = clampChannel(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2])
return toHex([gray, gray, gray])
}
/** Pull a color toward its own gray by `s` in [0,1] (1 = fully gray). */
export const desaturate = (color: string, s: number) => mix(color, grayOf(color), s)
/** RGB → HSL, channels in [0,1]. */
export function toHsl(rgb: Rgb): [number, number, number] {
const r = rgb[0] / 255
const g = rgb[1] / 255
const b = rgb[2] / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) {
return [0, 0, l]
}
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4
return [h / 6, s, l]
}
const hueChannel = (p: number, q: number, t: number): number => {
const tt = t < 0 ? t + 1 : t > 1 ? t - 1 : t
if (tt < 1 / 6) {
return p + (q - p) * 6 * tt
}
if (tt < 1 / 2) {
return q
}
if (tt < 2 / 3) {
return p + (q - p) * (2 / 3 - tt) * 6
}
return p
}
/** HSL → RGB, inputs in [0,1]. */
export function fromHsl(h: number, s: number, l: number): Rgb {
if (s === 0) {
const v = Math.round(l * 255)
return [v, v, v]
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
return [
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
Math.round(hueChannel(p, q, h) * 255),
Math.round(hueChannel(p, q, h - 1 / 3) * 255)
]
}
/**
* Re-tone a color while PRESERVING its hue: clamp saturation into
* [minSaturation, 1] and pin lightness. This is how a light-terminal variant
* of a pastel dark-terminal accent stays vivid — darkening by mixing toward
* black muddies pastels (kills saturation with lightness); re-toning keeps
* the color identity and moves only where it sits.
*/
export function retone(color: string, lightness: number, minSaturation = 0): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const [h, s] = toHsl(rgb)
return toHex(fromHsl(h, Math.max(s, minSaturation), lightness))
}
/** Multiply HSL saturation by `factor` (clamped to 1), hue/lightness fixed. */
export function boostSaturation(color: string, factor: number): string {
const rgb = parseColor(color)
if (!rgb) {
return color
}
const [h, s, l] = toHsl(rgb)
if (s === 0) {
return color
}
return toHex(fromHsl(h, Math.min(1, s * factor), l))
}
/**
* Chainable form for multi-step derivations, e.g.
* `color(text).mix(bg, 0.35).mix(accent, 0.18).ensureContrast(bg, 2.8).hex()`.
* Unparseable inputs pass through every operation unchanged.
*/
export function color(value: string): ColorChain {
return new ColorChain(value)
}
class ColorChain {
constructor(private readonly value: string) {}
mix(other: string, t: number): ColorChain {
return new ColorChain(mix(this.value, other, t))
}
lighten(t: number): ColorChain {
return new ColorChain(lighten(this.value, t))
}
darken(t: number): ColorChain {
return new ColorChain(darken(this.value, t))
}
ensureContrast(bg: string, min: number): ColorChain {
return new ColorChain(ensureContrast(this.value, bg, min))
}
luminance(): null | number {
return relativeLuminance(this.value)
}
contrastOn(bg: string): null | number {
return contrastRatio(this.value, bg)
}
hex(): string {
return this.value
}
}
+74
View File
@@ -0,0 +1,74 @@
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { beforeEach, describe, expect, it } from 'vitest'
import { resolveEditor } from './editor.js'
const exe = (dir: string, name: string): string => {
const path = join(dir, name)
writeFileSync(path, '#!/bin/sh\nexit 0\n')
chmodSync(path, 0o755)
return path
}
describe('resolveEditor', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'editor-test-'))
})
it('honors $VISUAL above all else', () => {
expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toEqual(['helix'])
})
it('falls back to $EDITOR when $VISUAL is unset', () => {
expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toEqual(['nvim'])
})
it('shell-tokenizes editors with arguments', () => {
expect(resolveEditor({ EDITOR: 'code --wait', PATH: dir })).toEqual(['code', '--wait'])
expect(resolveEditor({ PATH: dir, VISUAL: 'emacsclient -t' })).toEqual(['emacsclient', '-t'])
})
it('ignores whitespace-only env vars', () => {
const expected = exe(dir, 'editor')
expect(resolveEditor({ EDITOR: ' ', PATH: dir, VISUAL: '' })).toEqual([expected])
})
it('prefers `editor` over nano over vi on $PATH', () => {
exe(dir, 'nano')
exe(dir, 'vi')
const expected = exe(dir, 'editor')
expect(resolveEditor({ PATH: dir })).toEqual([expected])
})
it('falls back to nano before vi when both exist', () => {
exe(dir, 'vi')
const expected = exe(dir, 'nano')
expect(resolveEditor({ PATH: dir })).toEqual([expected])
})
it('returns ["vi"] when $PATH is empty', () => {
expect(resolveEditor({ PATH: '' })).toEqual(['vi'])
})
it('walks multi-entry $PATH', () => {
const a = mkdtempSync(join(tmpdir(), 'editor-a-'))
const b = mkdtempSync(join(tmpdir(), 'editor-b-'))
const expected = exe(b, 'editor')
expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toEqual([expected])
})
it('uses notepad.exe on Windows when no env override', () => {
expect(resolveEditor({ PATH: dir }, 'win32')).toEqual(['notepad.exe'])
})
})
+70
View File
@@ -0,0 +1,70 @@
import { spawnSync } from 'node:child_process'
import { accessSync, constants, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { withInkSuspended } from '@hermes/ink'
/**
* Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors
* prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and
* the TUI launch the same editor on a given box.
*/
const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs']
const isExecutable = (path: string): boolean => {
try {
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Resolve the editor invocation argv (without the file argument).
*
* 1. $VISUAL / $EDITOR, shell-tokenized so `EDITOR="code --wait"` works
* 2. on POSIX: first FALLBACKS entry resolvable on $PATH
* 3. on Windows: `notepad.exe`
* 4. literal `['vi']` as the last-resort POSIX floor
*/
export const resolveEditor = (
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform
): string[] => {
const explicit = env.VISUAL ?? env.EDITOR
if (explicit?.trim()) {
return explicit.trim().split(/\s+/)
}
if (platform === 'win32') {
return ['notepad.exe']
}
const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean)
const found = FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable)
return [found ?? 'vi']
}
/** Suspend Ink, open ``initial`` in $EDITOR, return the edited text (null if aborted). */
export async function openInEditor(initial: string, suffix = '.txt'): Promise<null | string> {
const dir = mkdtempSync(join(tmpdir(), 'hermes-edit-'))
const file = join(dir, `edit${suffix}`)
writeFileSync(file, initial)
const [cmd, ...args] = resolveEditor()
let status: null | number = null
await withInkSuspended(async () => {
status = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
})
try {
return status === 0 ? readFileSync(file, 'utf8') : null
} finally {
rmSync(dir, { force: true, recursive: true })
}
}
+55
View File
@@ -0,0 +1,55 @@
const VS15 = 0xfe0e
const VS16 = 0xfe0f
const KEYCAP = 0x20e3
const TEXT_DEFAULT_EMOJI = new Set<number>([
0x00a9, 0x00ae, 0x203c, 0x2049, 0x2122, 0x2139, 0x2194, 0x2195, 0x2196, 0x2197, 0x2198, 0x2199, 0x21a9, 0x21aa,
0x2328, 0x23cf, 0x23ed, 0x23ee, 0x23ef, 0x23f1, 0x23f2, 0x23f8, 0x23f9, 0x23fa, 0x24c2, 0x25aa, 0x25ab, 0x25b6,
0x25c0, 0x25fb, 0x25fc, 0x2600, 0x2601, 0x2602, 0x2603, 0x2604, 0x260e, 0x2611, 0x2618, 0x261d, 0x2620, 0x2622,
0x2623, 0x2626, 0x262a, 0x262e, 0x262f, 0x2638, 0x2639, 0x263a, 0x2640, 0x2642, 0x265f, 0x2660, 0x2663, 0x2665,
0x2666, 0x2668, 0x267b, 0x267e, 0x2692, 0x2694, 0x2695, 0x2696, 0x2697, 0x2699, 0x269b, 0x269c, 0x26a0, 0x26a7,
0x26b0, 0x26b1, 0x26c8, 0x26cf, 0x26d1, 0x26d3, 0x26d4, 0x26e9, 0x26f0, 0x26f1, 0x26f4, 0x26f7, 0x26f8, 0x26f9,
0x2702, 0x2708, 0x2709, 0x270c, 0x270d, 0x270f, 0x2712, 0x2714, 0x2716, 0x271d, 0x2721, 0x2733, 0x2734, 0x2744,
0x2747, 0x2763, 0x2764, 0x27a1, 0x2934, 0x2935, 0x2b05, 0x2b06, 0x2b07, 0x3030, 0x303d, 0x3297, 0x3299
])
const MAYBE_TEXT_EMOJI_RE =
/[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9\u21aa\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb\u25fc\u2600-\u2604\u260e\u2611\u2618\u261d\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u265f\u2660\u2663\u2665\u2666\u2668\u267b\u267e\u2692\u2694-\u2697\u2699\u269b\u269c\u26a0\u26a7\u26b0\u26b1\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26f0\u26f1\u26f4\u26f7-\u26f9\u2702\u2708\u2709\u270c\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u3030\u303d\u3297\u3299]/
export function ensureEmojiPresentation(text: string): string {
if (!text || !MAYBE_TEXT_EMOJI_RE.test(text)) {
return text
}
// Lazy output: only start building when we actually need to insert VS16.
// Short-circuits the whole walk for strings where every text-default emoji
// is already followed by VS16/VS15, avoiding per-codepoint string growth.
let out: null | string = null
let last = 0
let i = 0
while (i < text.length) {
const cp = text.codePointAt(i)!
const size = cp > 0xffff ? 2 : 1
if (TEXT_DEFAULT_EMOJI.has(cp)) {
const next = text.codePointAt(i + size)
// Skip only when the sequence already carries an explicit presentation
// selector. VS16 means the user (or a prior pass) already requested
// emoji presentation; VS15 is an explicit text-presentation request so
// leave it alone and don't pile VS16 on top of it. Inject before ZWJ
// and KEYCAP so ZWJ-joined sequences (e.g. ❤️‍🔥) and digit keycaps
// both render as emoji rather than text.
if (next !== VS16 && next !== VS15) {
out ??= ''
out += text.slice(last, i + size) + '\uFE0F'
last = i + size
}
}
i += size
}
return out === null ? text : out + text.slice(last)
}
+16
View File
@@ -0,0 +1,16 @@
import { spawn } from 'node:child_process'
export interface LaunchResult {
code: null | number
error?: string
}
const resolveHermesBin = () => process.env.HERMES_BIN?.trim() || 'hermes'
export const launchHermesCommand = (args: string[]): Promise<LaunchResult> =>
new Promise(resolve => {
const child = spawn(resolveHermesBin(), args, { stdio: 'inherit' })
child.on('error', err => resolve({ code: null, error: err.message }))
child.on('exit', code => resolve({ code }))
})
+440
View File
@@ -0,0 +1,440 @@
import { isIP } from 'node:net'
import { useEffect, useMemo, useState } from 'react'
const titleCache = new Map<string, string>()
const titleInflight = new Map<string, Promise<string>>()
const titleSubs = new Map<string, Set<(value: string) => void>>()
const TITLE_CACHE_LIMIT = 500
const TITLE_MAX_LENGTH = 240
const TITLE_BYTE_BUDGET = 96 * 1024
const TITLE_TIMEOUT_MS = 5000
const TITLE_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'
const TITLE_ERROR_RE =
/\b(?:access denied|attention required|captcha|error|forbidden|just a moment|not found|request blocked|too many requests)\b/i
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOSTNAME_RE = /^(?:localhost|localhost\.localdomain)$/i
const LOCAL_HOST_SUFFIXES = ['.corp', '.home', '.internal', '.lan', '.local', '.localdomain']
const STATUS_PERMALINK_HOST_RE = /^(?:mobile\.)?(?:x|twitter)\.com$/i
const STATUS_PERMALINK_PATH_RE = /^\/[^/]+\/status\/\d+\/?$/i
const HTML_ENTITIES: Record<string, string> = {
'#39': "'",
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: ' ',
quot: '"'
}
export function normalizeExternalUrl(value: string): string {
const trimmed = value.trim()
if (!trimmed || /^https?:\/\//i.test(trimmed)) {
return trimmed
}
return DOMAIN_RE.test(trimmed) ? `https://${trimmed}` : trimmed
}
function parseUrl(value: string): null | URL {
try {
return new URL(normalizeExternalUrl(value))
} catch {
return null
}
}
function titleCacheKey(value: string): string {
const url = parseUrl(value)
if (!url) {
return normalizeExternalUrl(value)
}
const host = url.hostname.replace(/^www\./i, '').toLowerCase()
const pathname = url.pathname === '/' ? '/' : url.pathname.replace(/\/+$/, '') || '/'
return `${host}${pathname}${url.search || ''}`
}
function cacheTitle(key: string, title: string): void {
if (titleCache.size >= TITLE_CACHE_LIMIT) {
titleCache.delete(titleCache.keys().next().value as string)
}
titleCache.set(key, title)
}
export function hostPathLabel(value: string): string {
const url = parseUrl(value)
if (!url) {
return value
}
const host = url.hostname.replace(/^www\./, '')
const path = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : ''
return `${host}${path}`
}
function cleanSlug(segment: string): string {
try {
return decodeURIComponent(segment)
.replace(/\.a\d+\..*$/i, '')
.replace(/\.(?:html?|php|aspx?)$/i, '')
.replace(/(?:[-_.](?:[a-z]{1,3}\d{2,}|i\d{2,}))+$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
} catch {
return ''
}
}
export function urlSlugTitleLabel(value: string): string {
const url = parseUrl(value)
if (url && STATUS_PERMALINK_HOST_RE.test(url.hostname) && STATUS_PERMALINK_PATH_RE.test(url.pathname)) {
return hostPathLabel(value)
}
for (const segment of url?.pathname.split('/').filter(Boolean).reverse() ?? []) {
const cleaned = cleanSlug(segment)
if (!cleaned || !/[a-z]/i.test(cleaned)) {
continue
}
if (/^(?:[a-z]{1,3}\d+|\d+)$/i.test(cleaned.replace(/\s+/g, ''))) {
continue
}
const titled = cleaned.replace(/\b[a-z]/g, c => c.toUpperCase())
if (titled.length >= 4) {
return titled
}
}
return hostPathLabel(value)
}
function parseIpv4Octets(value: string): null | [number, number, number, number] {
const parts = value.split('.')
if (parts.length !== 4) {
return null
}
const octets: number[] = []
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) {
return null
}
const next = Number(part)
if (!Number.isInteger(next) || next < 0 || next > 255) {
return null
}
octets.push(next)
}
return [octets[0]!, octets[1]!, octets[2]!, octets[3]!]
}
function isPrivateIpv4(value: string): boolean {
const octets = parseIpv4Octets(value)
if (!octets) {
return false
}
const [a, b] = octets
return (
a === 0 ||
a === 10 ||
a === 127 ||
a === 255 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19))
)
}
function isPrivateIpv6(value: string): boolean {
const normalized = value.toLowerCase()
if (normalized === '::' || normalized === '::1') {
return true
}
if (normalized.startsWith('fc') || normalized.startsWith('fd')) {
return true
}
if (
normalized.startsWith('fe8') ||
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
) {
return true
}
if (normalized.startsWith('::ffff:')) {
return isPrivateIpv4(normalized.slice('::ffff:'.length))
}
return false
}
function normalizeHostname(value: string): string {
const withoutBrackets = value.replace(/^\[/, '').replace(/\]$/, '')
const withoutZoneId = withoutBrackets.split('%', 1)[0]!
return withoutZoneId.replace(/\.$/, '').toLowerCase()
}
function isPrivateOrLocalHost(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
if (!normalized) {
return true
}
if (LOCAL_HOSTNAME_RE.test(normalized)) {
return true
}
if (LOCAL_HOST_SUFFIXES.some(suffix => normalized.endsWith(suffix))) {
return true
}
const ipVersion = isIP(normalized)
if (ipVersion === 4) {
return isPrivateIpv4(normalized)
}
if (ipVersion === 6) {
return isPrivateIpv6(normalized)
}
// Single-label hostnames are usually LAN names or enterprise intranet aliases.
return !normalized.includes('.')
}
export function isTitleFetchable(value: string): boolean {
if (!value || SKIP_PROTO_RE.test(value)) {
return false
}
const url = parseUrl(value)
return Boolean(url && /^https?:$/.test(url.protocol) && !isPrivateOrLocalHost(url.hostname))
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&(amp|lt|gt|quot|apos|nbsp|#39);/gi, (_match, key: string) => HTML_ENTITIES[key.toLowerCase()] ?? '')
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(parseInt(hex, 16) || 32))
.replace(/&#(\d+);/g, (_match, decimal: string) => String.fromCodePoint(parseInt(decimal, 10) || 32))
}
function parseHtmlTitle(html: string): string {
const raw = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]
return raw ? decodeHtmlEntities(raw).replace(/\s+/g, ' ').trim() : ''
}
async function readResponseSnippet(response: Response): Promise<string> {
const reader = response.body?.getReader()
if (!reader) {
return (await response.text()).slice(0, TITLE_BYTE_BUDGET)
}
const chunks: Uint8Array[] = []
let done = false
let bytes = 0
try {
while (bytes < TITLE_BYTE_BUDGET) {
const chunk = await reader.read()
if (chunk.done) {
done = true
break
}
const value = chunk.value
if (!value?.length) {
continue
}
const remaining = TITLE_BYTE_BUDGET - bytes
const next = value.length > remaining ? value.subarray(0, remaining) : value
chunks.push(next)
bytes += next.length
if (next.length < value.length) {
break
}
}
} catch {
return ''
} finally {
if (!done) {
try {
await reader.cancel()
} catch {
// Ignore stream teardown failures.
}
}
}
if (!chunks.length) {
return ''
}
const joined = new Uint8Array(bytes)
let offset = 0
for (const chunk of chunks) {
joined.set(chunk, offset)
offset += chunk.length
}
return new TextDecoder().decode(joined)
}
function usableTitle(value: string): string {
const clean = value.replace(/\s+/g, ' ').trim()
return clean && !TITLE_ERROR_RE.test(clean) ? clean : ''
}
async function fetchHtmlTitle(normalizedUrl: string): Promise<string> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TITLE_TIMEOUT_MS)
try {
const response = await fetch(normalizedUrl, {
headers: {
Accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
'Accept-Language': 'en-US,en;q=0.7',
'User-Agent': TITLE_USER_AGENT
},
redirect: 'follow',
signal: controller.signal
})
if (!response.ok) {
return ''
}
const contentType = response.headers.get('content-type')
if (contentType && !/(?:html|xml|text\/html)/i.test(contentType)) {
return ''
}
const html = await readResponseSnippet(response)
return parseHtmlTitle(html).slice(0, TITLE_MAX_LENGTH)
} catch {
return ''
} finally {
clearTimeout(timeout)
}
}
export function fetchLinkTitle(url: string): Promise<string> {
const normalizedUrl = normalizeExternalUrl(url)
const key = titleCacheKey(normalizedUrl)
if (!isTitleFetchable(normalizedUrl)) {
return Promise.resolve('')
}
if (titleCache.has(key)) {
return Promise.resolve(titleCache.get(key) ?? '')
}
const pending = titleInflight.get(key)
if (pending) {
return pending
}
const promise = fetchHtmlTitle(normalizedUrl)
.then(usableTitle)
.catch(() => '')
.then(clean => {
cacheTitle(key, clean)
titleSubs.get(key)?.forEach(sub => sub(clean))
return clean
})
.finally(() => {
titleInflight.delete(key)
})
titleInflight.set(key, promise)
return promise
}
export function useLinkTitle(url?: null | string): string {
const normalizedUrl = useMemo(() => (url ? normalizeExternalUrl(url) : ''), [url])
const key = useMemo(() => (normalizedUrl ? titleCacheKey(normalizedUrl) : ''), [normalizedUrl])
const [title, setTitle] = useState(() => (key ? (titleCache.get(key) ?? '') : ''))
useEffect(() => {
setTitle(key ? (titleCache.get(key) ?? '') : '')
if (!key || !isTitleFetchable(normalizedUrl)) {
return
}
const subs = titleSubs.get(key) ?? new Set<(value: string) => void>()
subs.add(setTitle)
titleSubs.set(key, subs)
void fetchLinkTitle(normalizedUrl)
return () => {
subs.delete(setTitle)
if (!subs.size) {
titleSubs.delete(key)
}
}
}, [key, normalizedUrl])
return title
}
export function __resetLinkTitleCache(): void {
titleCache.clear()
titleInflight.clear()
titleSubs.clear()
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Targeted 24-bit truecolor override before chalk / supports-color imports.
*
* macOS Terminal.app before Tahoe 26 does not support RGB SGR, so do not
* infer truecolor from TERM_PROGRAM=Apple_Terminal. Users can still opt in
* explicitly on terminals that support RGB but do not advertise COLORTERM.
*/
const TRUE_RE = /^(?:1|true|yes|on)$/i
const FALSE_RE = /^(?:0|false|no|off)$/i
export function shouldForceTruecolor(env: NodeJS.ProcessEnv = process.env): boolean {
const override = (env.HERMES_TUI_TRUECOLOR ?? '').trim()
if (FALSE_RE.test(override) || 'NO_COLOR' in env) {
return false
}
return TRUE_RE.test(override)
}
const isAppleTerminal = (env: NodeJS.ProcessEnv = process.env) => (env.TERM_PROGRAM ?? '').trim() === 'Apple_Terminal'
const isAdvertisedTruecolor = (env: NodeJS.ProcessEnv = process.env) => {
const colorTerm = (env.COLORTERM ?? '').trim().toLowerCase()
const forceColor = (env.FORCE_COLOR ?? '').trim()
return colorTerm === 'truecolor' || colorTerm === '24bit' || forceColor === '3'
}
export function shouldDowngradeAppleTerminalTruecolor(env: NodeJS.ProcessEnv = process.env): boolean {
if (!isAppleTerminal(env)) {
return false
}
if (shouldForceTruecolor(env)) {
return false
}
return isAdvertisedTruecolor(env)
}
if (shouldForceTruecolor()) {
if (!process.env.COLORTERM) {
process.env.COLORTERM = 'truecolor'
}
process.env.FORCE_COLOR = '3'
} else if (shouldDowngradeAppleTerminalTruecolor()) {
// Terminal.app may advertise truecolor even when RGB SGR paths render
// incorrectly. Keep Hermes on the safer TERM-driven 256-color path unless
// users explicitly opt back in via HERMES_TUI_TRUECOLOR=1.
delete process.env.COLORTERM
if ((process.env.FORCE_COLOR ?? '').trim() === '3') {
delete process.env.FORCE_COLOR
}
}
export {}
+51
View File
@@ -0,0 +1,51 @@
// Tiny FPS tracker fed by ink's onFrame callback. Each entry is an Ink
// frame (React commit + drain-only frames) — the right notion for
// user-perceived motion.
//
// Zero-cost when HERMES_TUI_FPS is unset: trackFrame is undefined so the
// onFrame callback short-circuits at the optional chain.
import { atom } from 'nanostores'
import { SHOW_FPS } from '../config/env.js'
const WINDOW_SIZE = 30
export type FpsState = {
fps: number
/** Wraps at JS-safe int — diff pairs in a debug overlay safely. */
totalFrames: number
/** Ink render-phase total for the last frame. */
lastDurationMs: number
}
export const $fpsState = atom<FpsState>({ fps: 0, lastDurationMs: 0, totalFrames: 0 })
const timestamps: number[] = []
let totalFrames = 0
export const trackFrame = SHOW_FPS
? (durationMs: number) => {
timestamps.push(performance.now())
if (timestamps.length > WINDOW_SIZE) {
timestamps.shift()
}
totalFrames++
if (timestamps.length < 2) {
return
}
const elapsed = (timestamps[timestamps.length - 1]! - timestamps[0]!) / 1000
if (elapsed > 0) {
$fpsState.set({
fps: Math.round(((timestamps.length - 1) / elapsed) * 10) / 10,
lastDurationMs: Math.round(durationMs * 100) / 100,
totalFrames
})
}
}
: undefined
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { fuzzyRank, fuzzyScore, fuzzyScoreMulti } from './fuzzy.js'
describe('fuzzyScore', () => {
it('matches a query as a subsequence (g4o → gpt-4o)', () => {
expect(fuzzyScore('gpt-4o', 'g4o')).not.toBeNull()
expect(fuzzyScore('gpt-4o', 'gpt')).not.toBeNull()
expect(fuzzyScore('gpt-4o', '4o')).not.toBeNull()
})
it('returns null when characters are out of order or absent', () => {
expect(fuzzyScore('gpt-4o', 'o4g')).toBeNull()
expect(fuzzyScore('gpt-4o', 'xyz')).toBeNull()
expect(fuzzyScore('gpt-4o', 'gptx')).toBeNull()
})
it('returns matched positions into the original target', () => {
const m = fuzzyScore('gpt-4o', 'g4o')
// g@0, 4@4, o@5
expect(m?.positions).toEqual([0, 4, 5])
})
it('treats an empty query as a zero-score match', () => {
expect(fuzzyScore('anything', '')).toEqual({ score: 0, positions: [] })
})
it('scores an exact match highest', () => {
const exact = fuzzyScore('sonnet', 'sonnet')!.score
const prefix = fuzzyScore('sonnet-extended', 'sonnet')!.score
// s,o,n,n,e,t all present in order but scattered across word boundaries.
const scattered = fuzzyScore('snorkel-online-nnet', 'sonnet')!.score
expect(exact).toBeGreaterThan(prefix)
expect(prefix).toBeGreaterThan(scattered)
})
it('ranks a prefix match above a scattered subsequence', () => {
const prefix = fuzzyScore('gpt-4o-mini', 'gpt')!.score
const scattered = fuzzyScore('a-g-p-t', 'gpt')!.score
expect(prefix).toBeGreaterThan(scattered)
})
it('rewards word-boundary matches', () => {
// `s4` matching the `s` of sonnet and the `4` after a dash
const boundary = fuzzyScore('claude-sonnet-4', 'cs4')
expect(boundary).not.toBeNull()
})
})
describe('fuzzyScoreMulti', () => {
it('requires every space-separated token to match (AND)', () => {
expect(fuzzyScoreMulti('claude-sonnet-4', 'clad snnt')).not.toBeNull()
expect(fuzzyScoreMulti('claude-sonnet-4', 'claude haiku')).toBeNull()
})
it('unions matched positions across tokens, sorted', () => {
const m = fuzzyScoreMulti('claude-sonnet', 'son cla')
expect(m).not.toBeNull()
expect(m!.positions).toEqual([...m!.positions].sort((a, b) => a - b))
})
it('treats whitespace-only query as a zero-score match', () => {
expect(fuzzyScoreMulti('x', ' ')).toEqual({ score: 0, positions: [] })
})
})
describe('fuzzyRank', () => {
const models = ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4', 'claude-haiku', 'o1-preview']
it('drops non-matching items and ranks matches by score', () => {
const ranked = fuzzyRank(models, 'g4o', m => m)
const ids = ranked.map(r => r.item)
expect(ids).toContain('gpt-4o')
expect(ids).toContain('gpt-4o-mini')
expect(ids).not.toContain('claude-haiku')
// Shorter exact-ish prefix should outrank the longer variant.
expect(ids.indexOf('gpt-4o')).toBeLessThan(ids.indexOf('gpt-4o-mini'))
})
it('ranks son4 so a sonnet model surfaces', () => {
const ranked = fuzzyRank(models, 'son4', m => m)
expect(ranked[0]?.item).toBe('claude-sonnet-4')
})
it('returns all items in original order for an empty query', () => {
const ranked = fuzzyRank(models, '', m => m)
expect(ranked.map(r => r.item)).toEqual(models)
expect(ranked.every(r => r.positions.length === 0)).toBe(true)
})
it('is stable for equal scores (original index tiebreak)', () => {
const items = ['ab', 'ab', 'ab']
const ranked = fuzzyRank(
items.map((v, i) => ({ v, i })),
'ab',
x => x.v
)
expect(ranked.map(r => r.item.i)).toEqual([0, 1, 2])
})
it('matches across a derived key, not just the raw string', () => {
const providers = [
{ slug: 'openai', name: 'OpenAI' },
{ slug: 'anthropic', name: 'Anthropic' }
]
const ranked = fuzzyRank(providers, 'anth', p => `${p.name} ${p.slug}`)
expect(ranked[0]?.item.slug).toBe('anthropic')
})
})
+177
View File
@@ -0,0 +1,177 @@
// Lightweight fuzzy subsequence scorer for picker filtering.
//
// Matches a query as an ordered subsequence of the target (so `g4o` matches
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
// score is a better match. Returns the matched character indices so callers
// can highlight them.
//
// The scoring favours, in rough order: exact full match, prefix match, matches
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
// lower→upper case transition), contiguous runs, and earlier matches. This is
// intentionally simple — no external dependency — but good enough to make
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
//
// The WebUI ships a logically identical copy of this module at
// web/src/lib/fuzzy.ts (only prettier formatting differs); keep the two in
// sync. The TUI copy carries the vitest suite (the web package has no test
// runner), so changes should be validated here.
export interface FuzzyMatch {
/** Total score; higher is better. */
score: number
/** Indices into the original (non-lowercased) target that were matched. */
positions: number[]
}
const WORD_BOUNDARY = /[-_/.\s]/
function isBoundary(target: string, index: number): boolean {
if (index === 0) {
return true
}
const prev = target[index - 1]
if (WORD_BOUNDARY.test(prev)) {
return true
}
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
const cur = target[index]
return prev === prev.toLowerCase() && cur !== cur.toLowerCase() && cur === cur.toUpperCase()
}
/**
* Score a single query token against a target. Returns null when the token is
* not a subsequence of the target. An empty query scores 0 with no positions.
*/
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
if (!query) {
return { score: 0, positions: [] }
}
const lowerTarget = target.toLowerCase()
const lowerQuery = query.toLowerCase()
const positions: number[] = []
let score = 0
let prevIndex = -1
let searchFrom = 0
for (const ch of lowerQuery) {
const idx = lowerTarget.indexOf(ch, searchFrom)
if (idx < 0) {
return null
}
positions.push(idx)
// Base point for the matched character.
score += 1
// Contiguous with the previous match → strong bonus.
if (prevIndex >= 0 && idx === prevIndex + 1) {
score += 5
} else if (prevIndex >= 0) {
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
score -= Math.min(idx - prevIndex - 1, 3)
}
// Word-boundary / start-of-string matches are meaningful.
if (isBoundary(target, idx)) {
score += 3
}
// Matching the very first character of the target is the strongest signal.
if (idx === 0) {
score += 5
}
prevIndex = idx
searchFrom = idx + 1
}
// Prefix bonus: the query matched a contiguous prefix of the target.
if (positions.length && positions[0] === 0 && positions[positions.length - 1] === positions.length - 1) {
score += 8
}
// Exact full match dominates everything else.
if (lowerTarget === lowerQuery) {
score += 20
}
// Slightly prefer shorter targets when scores are otherwise close, so a
// query that fully prefixes a short id beats the same prefix on a long one.
score -= lowerTarget.length * 0.01
return { score, positions }
}
/**
* Score a target against a whitespace-separated, multi-token query. Every token
* must match (AND semantics); the result aggregates per-token scores and the
* union of matched positions. Returns null if any token fails to match.
*/
export function fuzzyScoreMulti(target: string, query: string): FuzzyMatch | null {
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!tokens.length) {
return { score: 0, positions: [] }
}
let score = 0
const positionSet = new Set<number>()
for (const token of tokens) {
const match = fuzzyScore(target, token)
if (!match) {
return null
}
score += match.score
for (const pos of match.positions) {
positionSet.add(pos)
}
}
return { score, positions: [...positionSet].sort((a, b) => a - b) }
}
export interface RankedItem<T> {
item: T
score: number
positions: number[]
}
/**
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
* items are dropped; matches are sorted by score (descending), ties broken by
* the original index so ordering is stable for equal scores. An empty query
* returns every item in original order with no positions.
*/
export function fuzzyRank<T>(items: readonly T[], query: string, toText: (item: T) => string): RankedItem<T>[] {
const trimmed = query.trim()
if (!trimmed) {
return items.map(item => ({ item, score: 0, positions: [] }))
}
const ranked: Array<RankedItem<T> & { index: number }> = []
items.forEach((item, index) => {
const match = fuzzyScoreMulti(toText(item), trimmed)
if (match) {
ranked.push({ item, score: match.score, positions: match.positions, index })
}
})
ranked.sort((a, b) => b.score - a.score || a.index - b.index)
return ranked.map(({ item, score, positions }) => ({ item, score, positions }))
}
+67
View File
@@ -0,0 +1,67 @@
interface SetupOptions {
cleanups?: (() => Promise<void> | void)[]
failsafeMs?: number
ignoredSignals?: GracefulSignal[]
onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void
onSignal?: (signal: NodeJS.Signals) => void
}
export type GracefulSignal = 'SIGHUP' | 'SIGINT' | 'SIGTERM'
const SIGNALS: readonly GracefulSignal[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
const SIGNAL_EXIT_CODE: Record<GracefulSignal, number> = {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143
}
let wired = false
export const shouldExitForSignal = (signal: GracefulSignal, ignoredSignals: readonly GracefulSignal[] = []) =>
!ignoredSignals.includes(signal)
export function setupGracefulExit({
cleanups = [],
failsafeMs = 4000,
ignoredSignals = [],
onError,
onSignal
}: SetupOptions = {}) {
if (wired) {
return
}
wired = true
let shuttingDown = false
const exit = (code: number, signal?: NodeJS.Signals) => {
if (shuttingDown) {
return
}
shuttingDown = true
if (signal) {
onSignal?.(signal)
}
setTimeout(() => process.exit(code), failsafeMs).unref?.()
void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code))
}
for (const sig of SIGNALS) {
process.on(sig, () => {
if (!shouldExitForSignal(sig, ignoredSignals)) {
return
}
exit(SIGNAL_EXIT_CODE[sig], sig)
})
}
process.on('uncaughtException', err => onError?.('uncaughtException', err))
process.on('unhandledRejection', reason => onError?.('unhandledRejection', reason))
}
+82
View File
@@ -0,0 +1,82 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const MAX = 1000
const dir = process.env.HERMES_HOME ?? join(homedir(), '.hermes')
const file = join(dir, '.hermes_history')
let cache: string[] | null = null
export function load() {
if (cache) {
return cache
}
try {
if (!existsSync(file)) {
cache = []
return cache
}
const entries: string[] = []
let current: string[] = []
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (line.startsWith('+')) {
current.push(line.slice(1))
} else if (current.length) {
entries.push(current.join('\n'))
current = []
}
}
if (current.length) {
entries.push(current.join('\n'))
}
cache = entries.slice(-MAX)
} catch {
cache = []
}
return cache
}
export function append(line: string) {
const trimmed = line.trim()
if (!trimmed) {
return
}
const items = load()
if (items.at(-1) === trimmed) {
return
}
items.push(trimmed)
if (items.length > MAX) {
items.splice(0, items.length - MAX)
}
try {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const ts = new Date().toISOString().replace('T', ' ').replace('Z', '')
const encoded = trimmed
.split('\n')
.map(l => `+${l}`)
.join('\n')
appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
} catch {
void 0
}
}
+203
View File
@@ -0,0 +1,203 @@
import { stringWidth, wrapAnsi } from '@hermes/ink'
import type { Role } from '../types.js'
export const COMPOSER_PROMPT_GAP_WIDTH = 1
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
interface VisualLine {
end: number
start: number
}
const graphemes = (value: string) =>
[...seg().segment(value)].map(({ segment, index }) => ({
end: index + segment.length,
index,
segment,
width: Math.max(1, stringWidth(segment))
}))
// Build VisualLines from wrap-ansi's output by mapping each emitted character
// back to its original offset in `value`. wrap-ansi only INSERTS '\n' at wrap
// boundaries — it never drops, reorders, or substitutes existing characters —
// so a parallel walk uniquely identifies each line's source range.
//
// This used to be a hand-rolled word-wrap whose break points disagreed with
// wrap-ansi in subtle but visible ways: exact-fill rows pushed the cursor to
// a phantom next line, mid-word breaks landed one grapheme off, etc. The
// composer's TextInput renders text via Ink's <Text wrap="wrap">, which
// delegates to wrap-ansi — so any drift between the two algorithms parks the
// hardware cursor several cells away from the last rendered character.
// Sourcing both from wrap-ansi guarantees agreement.
function visualLines(value: string, cols: number): VisualLine[] {
if (!value.length) {
return [{ start: 0, end: 0 }]
}
const width = Math.max(1, cols)
const wrapped = wrapAnsi(value, width, { hard: true, trim: false })
const lines: VisualLine[] = []
let originalIdx = 0
let lineStart = 0
for (let i = 0; i < wrapped.length; i += 1) {
const ch = wrapped[i]!
if (ch === '\n') {
// wrap-ansi inserts '\n' to mark a soft-wrap boundary OR copies a
// literal '\n' from the input. Either way the next char in `wrapped`
// begins a new visual line. If the source character is a hard '\n',
// consume it (it doesn't appear in either line). Otherwise the '\n'
// is purely a wrap marker and originalIdx stays put.
lines.push({ start: lineStart, end: originalIdx })
const isHardNewline = originalIdx < value.length && value[originalIdx] === '\n'
if (isHardNewline) {
originalIdx += 1
}
lineStart = originalIdx
continue
}
// Defensive sync check. wrap-ansi (with `hard: true, trim: false`, no
// styled input) is documented to only insert '\n' at break points and
// never substitute, drop, or reorder source characters — so under those
// options `wrapped[i]` should always equal `value[originalIdx]`. But
// future option changes, library upgrades, or callers that start passing
// styled input (ANSI escapes) could violate that invariant silently. If
// they do, we'd slide `originalIdx` past the end of `value` and emit
// garbage line ranges with no diagnostic. Realign by scanning forward
// for the matching character; bail out (return whatever we have) if the
// sync is unrecoverable rather than producing wrong-but-plausible output.
if (originalIdx >= value.length) {
break
}
if (value[originalIdx] !== ch) {
const reSync = value.indexOf(ch, originalIdx)
if (reSync === -1) {
break
}
originalIdx = reSync
}
originalIdx += 1
}
lines.push({ start: lineStart, end: originalIdx })
// wrap-ansi collapses an empty input into [""] which we already handled
// above; preserve the invariant that lines is never empty for any input.
return lines.length ? lines : [{ start: 0, end: 0 }]
}
function widthBetween(value: string, start: number, end: number) {
let width = 0
for (const part of graphemes(value.slice(start, end))) {
width += part.width
}
return width
}
/**
* Mirrors the word-wrap behavior used by the composer TextInput.
* Returns the zero-based visual line and column of the cursor cell.
*
* IMPORTANT: this MUST stay in lock-step with how Ink's `<Text wrap="wrap">`
* lays the value out (which uses `wrap-ansi`). Any divergence parks the
* hardware cursor several cells off the last rendered character — see the
* "cursor drift past blank cells" bug. `visualLines` is sourced directly
* from wrap-ansi to enforce that invariant.
*/
export function cursorLayout(value: string, cursor: number, cols: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
const w = Math.max(1, cols)
const lines = visualLines(value, w)
let lineIndex = 0
for (let i = 0; i < lines.length; i += 1) {
if (lines[i]!.start <= pos) {
lineIndex = i
} else {
break
}
}
const line = lines[lineIndex]!
const column = widthBetween(value, line.start, Math.min(pos, line.end))
// NOTE: the previous implementation forced an extra line break when
// `column >= w` (the "trailing cursor-cell overflows" rule). With
// `visualLines` sourcing breaks from wrap-ansi, the line wrapping
// above already matches what Ink will actually render. Pushing the
// cursor onto a phantom next line here would re-introduce the same
// drift we're fixing, so we don't.
return { column, line: lineIndex }
}
export function offsetFromPosition(value: string, row: number, col: number, cols: number) {
if (!value.length) {
return 0
}
const lines = visualLines(value, cols)
const target = lines[Math.max(0, Math.min(lines.length - 1, Math.floor(row)))]!
const targetCol = Math.max(0, Math.floor(col))
let column = 0
for (const part of graphemes(value.slice(target.start, target.end))) {
if (targetCol <= column + Math.max(0, part.width - 1)) {
return target.start + part.index
}
column += part.width
}
return target.end
}
export function inputVisualHeight(value: string, columns: number) {
return cursorLayout(value, value.length, columns).line + 1
}
export function composerPromptWidth(promptText: string) {
return Math.max(1, stringWidth(promptText)) + COMPOSER_PROMPT_GAP_WIDTH
}
export function transcriptGutterWidth(role: Role, userPrompt: string) {
return role === 'user' ? composerPromptWidth(userPrompt) : 3
}
export function transcriptBodyWidth(totalCols: number, role: Role, userPrompt: string, termuxMode = false) {
const horizontalReserve = termuxMode ? 2 : 4
const available = Math.max(1, totalCols - transcriptGutterWidth(role, userPrompt) - horizontalReserve)
if (termuxMode) {
// On narrow / unusual aspect-ratio mobile panes, forcing a wide minimum
// width causes right-edge clipping and chopped words.
return available
}
return Math.max(20, available)
}
export function stableComposerColumns(totalCols: number, promptWidth: number, termuxMode = false) {
// Physical render/wrap width. Always reserve outer composer padding and
// prompt prefix. Only reserve the transcript scrollbar gutter when the
// terminal is wide enough; on narrow panes, preserving input columns beats
// keeping gutters visually aligned.
const afterPrompt = totalCols - promptWidth
const reserveScrollbar = afterPrompt >= (termuxMode ? 36 : 24) ? 2 : 0
return Math.max(1, totalCols - promptWidth - 2 - reserveScrollbar)
}
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import type { Msg } from '../types.js'
import { appendToolShelfMessage, canHoldToolShelf, isTodoDone, mergeToolShelfInto } from './liveProgress.js'
describe('isTodoDone', () => {
it('only treats non-empty all-completed/cancelled lists as done', () => {
expect(isTodoDone([])).toBe(false)
expect(isTodoDone([{ content: 'x', id: 'x', status: 'completed' }])).toBe(true)
expect(isTodoDone([{ content: 'x', id: 'x', status: 'in_progress' }])).toBe(false)
expect(
isTodoDone([
{ content: 'x', id: 'x', status: 'completed' },
{ content: 'y', id: 'y', status: 'cancelled' }
])
).toBe(true)
})
})
describe('tool shelf helpers', () => {
it('recognizes contextual thinking shelves as holders', () => {
expect(canHoldToolShelf({ kind: 'trail', role: 'system', text: '', thinking: 'plan' })).toBe(true)
expect(canHoldToolShelf({ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] })).toBe(true)
expect(canHoldToolShelf({ role: 'assistant', text: 'done' })).toBe(false)
})
it('merges source rows into an existing shelf', () => {
expect(
mergeToolShelfInto(
{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] },
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
)
).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] })
})
})
describe('appendToolShelfMessage', () => {
it('merges adjacent tool shelves into one contextual shelf', () => {
const merged = appendToolShelfMessage([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }], {
kind: 'trail',
role: 'system',
text: '',
tools: ['two ✓']
})
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] }])
})
it('adds tools to the nearest contextual thinking shelf', () => {
const merged = appendToolShelfMessage(
[{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }],
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
)
expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }])
})
it('merges through intervening thinking-only rows back into the nearest holder', () => {
const prev: Msg[] = [
{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] },
{ kind: 'trail', role: 'system', text: '', thinking: 'more plan' }
]
const merged = appendToolShelfMessage(prev, {
kind: 'trail',
role: 'system',
text: '',
tools: ['two ✓']
})
expect(merged).toHaveLength(2)
expect(merged[0]).toEqual({
kind: 'trail',
role: 'system',
text: '',
thinking: 'plan',
tools: ['one ✓', 'two ✓']
})
expect(merged[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' })
})
it('collapses a chronological thinking/tool/thinking/tool stream into one shelf', () => {
const events: Msg[] = [
{ kind: 'trail', role: 'system', text: '', thinking: 'plan' },
{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] },
{ kind: 'trail', role: 'system', text: '', thinking: 'more plan' },
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] },
{ kind: 'trail', role: 'system', text: '', tools: ['three ✓'] }
]
const reduced = events.reduce<Msg[]>((acc, msg) => appendToolShelfMessage(acc, msg), [])
expect(reduced).toHaveLength(2)
expect(reduced[0]).toEqual({
kind: 'trail',
role: 'system',
text: '',
thinking: 'plan',
tools: ['one ✓', 'two ✓', 'three ✓']
})
expect(reduced[1]).toEqual({ kind: 'trail', role: 'system', text: '', thinking: 'more plan' })
})
it('starts a new shelf across assistant text boundaries', () => {
const merged = appendToolShelfMessage(
[
{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] },
{ role: 'assistant', text: 'done' }
],
{ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }
)
expect(merged).toHaveLength(3)
})
})
+79
View File
@@ -0,0 +1,79 @@
import type { Msg, TodoItem } from '../types.js'
export const countPendingTodos = (todos: readonly TodoItem[]) =>
todos.filter(todo => todo.status === 'in_progress' || todo.status === 'pending').length
export const isTodoDone = (todos: readonly TodoItem[]) =>
todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled')
export const isToolShelfMessage = (msg: Msg | undefined) =>
Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length)
export const canHoldToolShelf = (msg: Msg | undefined) =>
Boolean(msg?.kind === 'trail' && !msg.text && (msg.thinking?.trim() || msg.tools?.length))
export const mergeToolShelfInto = (target: Msg, source: Msg): Msg => ({
...target,
tools: [...(target.tools ?? []), ...(source.tools ?? [])]
})
const isBarrierMessage = (msg: Msg | undefined) => {
if (!msg) {
return true
}
// Assistant text, user input, intro/panel rows all terminate the shelf.
if (msg.kind === 'intro' || msg.kind === 'panel' || msg.kind === 'diff') {
return true
}
if (msg.role && msg.role !== 'system') {
return true
}
if (msg.text) {
return true
}
return false
}
const isToolCarryingTrail = (msg: Msg | undefined) => Boolean(msg?.kind === 'trail' && !msg.text && msg.tools?.length)
export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => {
if (!isToolShelfMessage(msg)) {
return [...prev, msg]
}
let fallbackHolder: number | null = null
for (let index = prev.length - 1; index >= 0; index--) {
const candidate = prev[index]
if (isToolCarryingTrail(candidate)) {
const next = [...prev]
next[index] = mergeToolShelfInto(candidate!, msg)
return next
}
if (fallbackHolder === null && canHoldToolShelf(candidate)) {
fallbackHolder = index
}
if (isBarrierMessage(candidate)) {
break
}
}
if (fallbackHolder !== null) {
const next = [...prev]
next[fallbackHolder] = mergeToolShelfInto(prev[fallbackHolder]!, msg)
return next
}
return [...prev, msg]
}
+783
View File
@@ -0,0 +1,783 @@
// Best-effort LaTeX → Unicode for inline / display math captured by the
// markdown renderer. The terminal can't typeset LaTeX, but Unicode covers
// most of what models actually emit: Greek letters, blackboard / fraktur /
// calligraphic capitals, set theory + logic operators, common arrows,
// sub/superscripts, and `\frac{a}{b}` collapsed to `a/b`.
//
// Design rules:
// • Pure regex pipeline. Anything we don't recognise is preserved
// verbatim (so a `\foo{bar}` we've never heard of still survives).
// A real LaTeX parser would be more correct but throws on partial
// input — terminal users would rather see the raw command than a
// parse-error placeholder.
// • Longest-match-first ordering on commands so `\le` doesn't shadow
// `\leq`, `\sub` doesn't shadow `\subseteq`, etc.
// • Word-boundary lookahead `(?![A-Za-z])` after each command so
// `\pix` (made-up command) doesn't get partially substituted as `π`.
// • `\mathbb{X}`, `\mathcal{X}`, `\mathfrak{X}` only handle a single
// letter argument — multi-letter `\mathbb{NN}` is rare and would
// need a real parser to do correctly.
// • Sub/super scripts only convert if EVERY character has a Unicode
// equivalent. Mixed content like `^{n+1}` falls back to the raw
// LaTeX so we don't emit `ⁿ+¹` (which has no `+` superscript glyph
// in some fonts and reads worse than the source).
const SYMBOLS: Record<string, string> = {
// Greek lowercase
'\\alpha': 'α',
'\\beta': 'β',
'\\gamma': 'γ',
'\\delta': 'δ',
'\\epsilon': 'ε',
'\\varepsilon': 'ε',
'\\zeta': 'ζ',
'\\eta': 'η',
'\\theta': 'θ',
'\\vartheta': 'ϑ',
'\\iota': 'ι',
'\\kappa': 'κ',
'\\lambda': 'λ',
'\\mu': 'μ',
'\\nu': 'ν',
'\\xi': 'ξ',
'\\pi': 'π',
'\\varpi': 'ϖ',
'\\rho': 'ρ',
'\\varrho': 'ϱ',
'\\sigma': 'σ',
'\\varsigma': 'ς',
'\\tau': 'τ',
'\\upsilon': 'υ',
'\\phi': 'φ',
'\\varphi': 'φ',
'\\chi': 'χ',
'\\psi': 'ψ',
'\\omega': 'ω',
// Greek uppercase
'\\Gamma': 'Γ',
'\\Delta': 'Δ',
'\\Theta': 'Θ',
'\\Lambda': 'Λ',
'\\Xi': 'Ξ',
'\\Pi': 'Π',
'\\Sigma': 'Σ',
'\\Upsilon': 'Υ',
'\\Phi': 'Φ',
'\\Psi': 'Ψ',
'\\Omega': 'Ω',
// Big operators
'\\sum': '∑',
'\\prod': '∏',
'\\coprod': '∐',
'\\int': '∫',
'\\iint': '∬',
'\\iiint': '∭',
'\\oint': '∮',
'\\bigcup': '⋃',
'\\bigcap': '⋂',
'\\bigvee': '⋁',
'\\bigwedge': '⋀',
'\\bigoplus': '⨁',
'\\bigotimes': '⨂',
// Calculus
'\\partial': '∂',
'\\nabla': '∇',
'\\sqrt': '√',
// Sets
'\\emptyset': '∅',
'\\varnothing': '∅',
'\\infty': '∞',
'\\in': '∈',
'\\notin': '∉',
'\\ni': '∋',
'\\subset': '⊂',
'\\supset': '⊃',
'\\subseteq': '⊆',
'\\supseteq': '⊇',
'\\subsetneq': '⊊',
'\\supsetneq': '⊋',
'\\cup': '∪',
'\\cap': '∩',
'\\setminus': '∖',
'\\complement': '∁',
// Logic
'\\forall': '∀',
'\\exists': '∃',
'\\nexists': '∄',
'\\land': '∧',
'\\lor': '∨',
'\\lnot': '¬',
'\\neg': '¬',
'\\therefore': '∴',
'\\because': '∵',
// Relations
'\\le': '≤',
'\\leq': '≤',
'\\ge': '≥',
'\\geq': '≥',
'\\ne': '≠',
'\\neq': '≠',
'\\ll': '≪',
'\\gg': '≫',
'\\approx': '≈',
'\\equiv': '≡',
'\\cong': '≅',
'\\sim': '∼',
'\\simeq': '≃',
'\\propto': '∝',
'\\perp': '⊥',
'\\parallel': '∥',
'\\models': '⊨',
'\\vdash': '⊢',
'\\mid': '∣',
'\\nmid': '∤',
'\\divides': '∣',
// Common standalone glyphs
'\\blacksquare': '■',
'\\square': '□',
'\\Box': '□',
'\\qed': '∎',
'\\bigstar': '★',
// Modular arithmetic — the `\pmod{p}` form (with arg) is handled below;
// the bare `\bmod` / `\mod` commands are simple text substitutions.
'\\bmod': 'mod',
'\\mod': 'mod',
// Brackets / fences (named delimiter commands; the `\left\X` / `\right\X`
// unwrapping below leaves these behind for the symbol pass to resolve).
'\\langle': '⟨',
'\\rangle': '⟩',
'\\lceil': '⌈',
'\\rceil': '⌉',
'\\lfloor': '⌊',
'\\rfloor': '⌋',
'\\|': '‖',
// Arrows
'\\to': '→',
'\\rightarrow': '→',
'\\leftarrow': '←',
'\\leftrightarrow': '↔',
'\\Rightarrow': '⇒',
'\\Leftarrow': '⇐',
'\\Leftrightarrow': '⇔',
'\\implies': '⟹',
'\\impliedby': '⟸',
'\\iff': '⟺',
'\\mapsto': '↦',
'\\hookrightarrow': '↪',
'\\hookleftarrow': '↩',
'\\uparrow': '↑',
'\\downarrow': '↓',
'\\updownarrow': '↕',
// Binary operators
'\\cdot': '⋅',
'\\cdots': '⋯',
'\\ldots': '…',
'\\dots': '…',
'\\dotsb': '…',
'\\dotsc': '…',
'\\vdots': '⋮',
'\\ddots': '⋱',
'\\times': '×',
'\\div': '÷',
'\\pm': '±',
'\\mp': '∓',
'\\circ': '∘',
'\\bullet': '•',
'\\star': '⋆',
'\\ast': '∗',
'\\oplus': '⊕',
'\\ominus': '⊖',
'\\otimes': '⊗',
'\\odot': '⊙',
'\\diamond': '⋄',
'\\angle': '∠',
'\\triangle': '△',
// Spacing — collapse to varying widths of regular space
'\\,': ' ',
'\\;': ' ',
'\\:': ' ',
'\\!': '',
'\\ ': ' ',
'\\quad': ' ',
'\\qquad': ' ',
// Functions (LaTeX renders these in roman; we just keep the name)
'\\sin': 'sin',
'\\cos': 'cos',
'\\tan': 'tan',
'\\cot': 'cot',
'\\sec': 'sec',
'\\csc': 'csc',
'\\arcsin': 'arcsin',
'\\arccos': 'arccos',
'\\arctan': 'arctan',
'\\sinh': 'sinh',
'\\cosh': 'cosh',
'\\tanh': 'tanh',
'\\log': 'log',
'\\ln': 'ln',
'\\exp': 'exp',
'\\det': 'det',
'\\dim': 'dim',
'\\ker': 'ker',
'\\lim': 'lim',
'\\liminf': 'liminf',
'\\limsup': 'limsup',
'\\sup': 'sup',
'\\inf': 'inf',
'\\max': 'max',
'\\min': 'min',
'\\arg': 'arg',
'\\gcd': 'gcd',
// Escaped literals — model occasionally emits these for display
'\\&': '&',
'\\%': '%',
'\\$': '$',
'\\#': '#',
'\\_': '_',
'\\{': '{',
'\\}': '}'
}
const BB: Record<string, string> = {
A: '𝔸',
B: '𝔹',
C: 'ℂ',
D: '𝔻',
E: '𝔼',
F: '𝔽',
G: '𝔾',
H: 'ℍ',
I: '𝕀',
J: '𝕁',
K: '𝕂',
L: '𝕃',
M: '𝕄',
N: 'ℕ',
O: '𝕆',
P: 'ℙ',
Q: 'ℚ',
R: 'ℝ',
S: '𝕊',
T: '𝕋',
U: '𝕌',
V: '𝕍',
W: '𝕎',
X: '𝕏',
Y: '𝕐',
Z: 'ℤ'
}
const CAL: Record<string, string> = {
A: '𝒜',
B: 'ℬ',
C: '𝒞',
D: '𝒟',
E: 'ℰ',
F: 'ℱ',
G: '𝒢',
H: 'ℋ',
I: 'ℐ',
J: '𝒥',
K: '𝒦',
L: 'ℒ',
M: 'ℳ',
N: '𝒩',
O: '𝒪',
P: '𝒫',
Q: '𝒬',
R: 'ℛ',
S: '𝒮',
T: '𝒯',
U: '𝒰',
V: '𝒱',
W: '𝒲',
X: '𝒳',
Y: '𝒴',
Z: '𝒵'
}
const FRAK: Record<string, string> = {
A: '𝔄',
B: '𝔅',
C: 'ℭ',
D: '𝔇',
E: '𝔈',
F: '𝔉',
G: '𝔊',
H: 'ℌ',
I: 'ℑ',
J: '𝔍',
K: '𝔎',
L: '𝔏',
M: '𝔐',
N: '𝔑',
O: '𝔒',
P: '𝔓',
Q: '𝔔',
R: 'ℜ',
S: '𝔖',
T: '𝔗',
U: '𝔘',
V: '𝔙',
W: '𝔚',
X: '𝔛',
Y: '𝔜',
Z: 'ℨ'
}
const SUPERSCRIPT: Record<string, string> = {
'0': '⁰',
'1': '¹',
'2': '²',
'3': '³',
'4': '⁴',
'5': '⁵',
'6': '⁶',
'7': '⁷',
'8': '⁸',
'9': '⁹',
'+': '⁺',
'-': '⁻',
'=': '⁼',
'(': '⁽',
')': '⁾',
a: 'ᵃ',
b: 'ᵇ',
c: 'ᶜ',
d: 'ᵈ',
e: 'ᵉ',
f: 'ᶠ',
g: 'ᵍ',
h: 'ʰ',
i: 'ⁱ',
j: 'ʲ',
k: 'ᵏ',
l: 'ˡ',
m: 'ᵐ',
n: 'ⁿ',
o: 'ᵒ',
p: 'ᵖ',
r: 'ʳ',
s: 'ˢ',
t: 'ᵗ',
u: 'ᵘ',
v: 'ᵛ',
w: 'ʷ',
x: 'ˣ',
y: 'ʸ',
z: 'ᶻ'
}
const SUBSCRIPT: Record<string, string> = {
'0': '₀',
'1': '₁',
'2': '₂',
'3': '₃',
'4': '₄',
'5': '₅',
'6': '₆',
'7': '₇',
'8': '₈',
'9': '₉',
'+': '₊',
'-': '₋',
'=': '₌',
'(': '₍',
')': '₎',
a: 'ₐ',
e: 'ₑ',
h: 'ₕ',
i: 'ᵢ',
j: 'ⱼ',
k: 'ₖ',
l: 'ₗ',
m: 'ₘ',
n: 'ₙ',
o: 'ₒ',
p: 'ₚ',
r: 'ᵣ',
s: 'ₛ',
t: 'ₜ',
u: 'ᵤ',
v: 'ᵥ',
x: 'ₓ'
}
// Sentinel control characters used to mark `\boxed` / `\fbox` regions in
// the converted output. The renderer splits on these to apply a highlight
// style; consumers that don't want highlighting can strip them with the
// exported `BOX_RE` below.
export const BOX_OPEN = '\u0001'
export const BOX_CLOSE = '\u0002'
// eslint-disable-next-line no-control-regex -- intentional sentinel control chars
export const BOX_RE = /\u0001([^\u0001\u0002]*)\u0002/g
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Pre-compile two symbol regexes: one for letter-ending commands (`\pi`,
// `\sum`) which need a `(?![A-Za-z])` lookahead so they don't partially
// match `\pix` or `\summa`, and one for punctuation-ending commands
// (`\{`, `\,`, `\|`) which must NOT have the lookahead — otherwise
// `\{p` would refuse to substitute because `p` is a letter.
//
// Longest commands first inside each group so `\leq` beats `\le`.
const splitByEnding = (keys: string[]) => {
const letter: string[] = []
const punct: string[] = []
for (const k of keys) {
if (/[A-Za-z]$/.test(k)) {
letter.push(k)
} else {
punct.push(k)
}
}
return { letter, punct }
}
const buildAlt = (cmds: string[]) =>
cmds
.sort((a, b) => b.length - a.length)
.map(escapeRe)
.join('|')
const { letter: LETTER_CMDS, punct: PUNCT_CMDS } = splitByEnding(Object.keys(SYMBOLS))
const SYMBOL_LETTER_RE = new RegExp('(?:' + buildAlt(LETTER_CMDS) + ')(?![A-Za-z])', 'g')
const SYMBOL_PUNCT_RE = new RegExp('(?:' + buildAlt(PUNCT_CMDS) + ')', 'g')
const convertScript = (input: string, table: Record<string, string>, sigil: '^' | '_'): string => {
let out = ''
let allMapped = true
for (const ch of input) {
const mapped = table[ch]
if (!mapped) {
allMapped = false
break
}
out += mapped
}
if (allMapped) {
return out
}
// Fallback: if the body is a single visible character (e.g. `∞` after
// earlier symbol substitution), render it without braces — `^∞` reads
// far better than `^{∞}` in a terminal. Multi-char bodies that don't
// fully convert use parens (`e^(iπ)`) instead of braces (`e^{iπ}`)
// because parens are normal punctuation while braces look like
// unrendered LaTeX.
const trimmed = input.trim()
if ([...trimmed].length === 1) {
return `${sigil}${trimmed}`
}
return `${sigil}(${trimmed})`
}
// Walk the string and parse `{...}` honouring nested braces. Unlike a
// `\{[^{}]*\}` regex this survives `\frac{|t|^{p-1}|P(t)|^p}{...}` where
// the numerator contains its own braces from a superscript. Returns the
// inner content (without the outer braces) and the offset just past the
// closing `}`. Returns null if there is no balanced brace at `start`.
const readBraced = (s: string, start: number): { content: string; end: number } | null => {
if (s[start] !== '{') {
return null
}
let depth = 1
let i = start + 1
while (i < s.length && depth > 0) {
const c = s[i]
// Skip escapes — `\{` and `\}` inside a body are literal braces and
// should not change the brace counter.
if (c === '\\' && i + 1 < s.length) {
i += 2
continue
}
if (c === '{') {
depth++
} else if (c === '}') {
depth--
}
if (depth > 0) {
i++
}
}
if (depth !== 0) {
return null
}
return { content: s.slice(start + 1, i), end: i + 1 }
}
// Replace every occurrence of `\command{arg}` using balanced-brace parsing
// (so `\boxed{x^{n+1}}` works where a `[^{}]*` regex would fail). The
// `render` callback receives the inner content already recursed-into, so
// `\boxed{\boxed{x}}` resolves outside-in cleanly. Unmatched `\command`
// (no following `{...}`) is preserved verbatim.
const replaceBracedCommand = (input: string, command: string, render: (content: string) => string): string => {
const cmdLen = command.length
let out = ''
let i = 0
while (i < input.length) {
const idx = input.indexOf(command, i)
if (idx < 0) {
out += input.slice(i)
return out
}
const after = input[idx + cmdLen]
if (after && /[A-Za-z]/.test(after)) {
out += input.slice(i, idx + cmdLen)
i = idx + cmdLen
continue
}
out += input.slice(i, idx)
let p = idx + cmdLen
while (input[p] === ' ' || input[p] === '\t') {
p++
}
const arg = readBraced(input, p)
if (!arg) {
out += input.slice(idx, p + 1)
i = p + 1
continue
}
out += render(replaceBracedCommand(arg.content, command, render))
i = arg.end
}
return out
}
// Replace every `\frac{num}{den}` with `num/den` (parens around either
// side when its precedence demands it). The recursion handles nested
// fractions naturally: `\frac{1}{\frac{1}{x}}` collapses to `1/(1/x)`
// because we recurse into `den` before deciding whether to parenthesise.
const replaceFracs = (input: string): string => {
let out = ''
let i = 0
while (i < input.length) {
const idx = input.indexOf('\\frac', i)
if (idx < 0) {
out += input.slice(i)
return out
}
const after = input[idx + 5]
// `(?![A-Za-z])` — protect hypothetical commands like `\fraction`.
if (after && /[A-Za-z]/.test(after)) {
out += input.slice(i, idx + 5)
i = idx + 5
continue
}
out += input.slice(i, idx)
let p = idx + 5
while (input[p] === ' ' || input[p] === '\t') {
p++
}
const num = readBraced(input, p)
if (!num) {
out += input.slice(idx, p + 1)
i = p + 1
continue
}
p = num.end
while (input[p] === ' ' || input[p] === '\t') {
p++
}
const den = readBraced(input, p)
if (!den) {
out += input.slice(idx, p + 1)
i = p + 1
continue
}
out += `${wrapForFrac(replaceFracs(num.content))}/${wrapForFrac(replaceFracs(den.content))}`
i = den.end
}
return out
}
// Wrap multi-token expressions in parens so `\frac{a+b}{c}` becomes
// `(a+b)/c` rather than `a+b/c`. We wrap whenever inline `/` would
// change the meaning — that's any binary operator (`+`, `-`, `*`, `/`)
// or whitespace separating tokens. `*` and `/` matter because nested
// fractions and products like `\frac{a*b}{c}` and `\frac{1/x}{y}` would
// otherwise read as `a*b/c` (right-associative ambiguity) and `1/x/y`.
// Atomic factors like `n!`, `x^2`, `\sin x` don't trigger any of these
// and stay un-parenthesised — wrapping them just clutters the output.
const wrapForFrac = (expr: string) => {
const trimmed = expr.trim()
if (!trimmed) {
return trimmed
}
if (/^\(.*\)$/.test(trimmed)) {
return trimmed
}
if (/[+\-/*]|\s/.test(trimmed)) {
return `(${trimmed})`
}
return trimmed
}
export function texToUnicode(input: string): string {
let s = input
s = s.replace(/\\mathbb\s*\{([A-Za-z])\}/g, (raw, c: string) => BB[c] ?? raw)
s = s.replace(/\\mathcal\s*\{([A-Za-z])\}/g, (raw, c: string) => CAL[c] ?? raw)
s = s.replace(/\\mathfrak\s*\{([A-Za-z])\}/g, (raw, c: string) => FRAK[c] ?? raw)
s = s.replace(/\\mathbf\s*\{([^{}]+)\}/g, (_, c: string) => c)
s = s.replace(/\\mathit\s*\{([^{}]+)\}/g, (_, c: string) => c)
s = s.replace(/\\mathrm\s*\{([^{}]+)\}/g, (_, c: string) => c)
s = s.replace(/\\text\s*\{([^{}]+)\}/g, (_, c: string) => c)
s = s.replace(/\\operatorname\s*\{([^{}]+)\}/g, (_, c: string) => c)
s = s.replace(/\\overline\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0305`)
s = s.replace(/\\hat\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0302`)
s = s.replace(/\\bar\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0304`)
s = s.replace(/\\tilde\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0303`)
s = s.replace(/\\vec\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u20D7`)
s = s.replace(/\\dot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0307`)
s = s.replace(/\\ddot\s*\{([^{}]+)\}/g, (_, c: string) => `${c}\u0308`)
s = replaceFracs(s)
// `\boxed{X}` / `\fbox{X}` highlight a final answer. Terminals can't
// draw a real box, so we wrap the content in U+0001 / U+0002 control
// characters — non-printable, never present in real text — and let the
// markdown renderer split on them and apply a highlight style (inverse
// video) to the bracketed region. This keeps `texToUnicode` pure-string
// while letting the React layer do the actual visual emphasis.
// Argument is parsed with balanced braces so nested `{...}` from
// superscripts / fractions inside the box survive.
s = replaceBracedCommand(s, '\\boxed', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`)
s = replaceBracedCommand(s, '\\fbox', body => `${BOX_OPEN}${body.trim()}${BOX_CLOSE}`)
// `\xrightarrow{label}` / `\xleftarrow{label}` collapse to an arrow with
// the label inline. LaTeX renders the label above the arrow; in monospace
// we put it adjacent — `─label→` is the closest readable approximation.
// Run before the symbol pass so the label can still pick up Greek and
// operator substitutions afterwards.
s = s.replace(/\\xrightarrow\s*\{([^{}]*)\}/g, (_, label: string) => `─${label.trim()}→`)
s = s.replace(/\\xleftarrow\s*\{([^{}]*)\}/g, (_, label: string) => `←${label.trim()}─`)
s = s.replace(/\\Longrightarrow/g, '⟹')
s = s.replace(/\\Longleftarrow/g, '⟸')
s = s.replace(/\\Longleftrightarrow/g, '⟺')
// `\pmod{p}` → ` (mod p)` (LaTeX adds parens automatically); `\pod{p}`
// is a paren-less variant; `\tag{n}` is the equation-number annotation
// shown to the right of an equation. Collapse to a single-space-prefixed
// bracketed form. The leading `\s*` in the pattern absorbs any whitespace
// already in the source so we don't end up with `b (mod p)` (double
// space) when the user wrote `b \pmod{p}`.
s = s.replace(/\s*\\pmod\s*\{([^{}]*)\}/g, (_, p: string) => ` (mod ${p.trim()})`)
s = s.replace(/\s*\\pod\s*\{([^{}]*)\}/g, (_, p: string) => ` (${p.trim()})`)
s = s.replace(/\s*\\tag\s*\{([^{}]*)\}/g, (_, n: string) => ` (${n.trim()})`)
// `\big`, `\Big`, `\bigg`, `\Bigg` (with optional `l`/`r`/`m` suffix)
// are sizing wrappers analogous to `\left`/`\right` but without the
// automatic-pairing semantics. Strip them and leave whatever delimiter
// follows. The trailing `(?![A-Za-z])` protects `\bigtriangleup` and
// any other letter-continuation command from being shaved.
s = s.replace(/\\(?:Bigg|bigg|Big|big)[lrm]?(?![A-Za-z])/g, '')
// Style / size hints that don't typeset any glyph and only affect how
// things would be sized in a real LaTeX engine. In a terminal every
// glyph is one monospace cell, so there's nothing to do — drop them
// (with any trailing whitespace) so they don't leak through as raw
// `\displaystyle` in the output.
s = s.replace(/\\(?:scriptscriptstyle|displaystyle|scriptstyle|textstyle|nolimits|limits)(?![A-Za-z])\s*/g, '')
// `\left` and `\right` are sizing wrappers around any delimiter — bare
// (`\left(`), escaped (`\left\{`), or named (`\left\langle`). Strip the
// wrapper unconditionally and let the rest of the pipeline (or the
// upcoming symbol pass) handle whatever delimiter follows. The optional
// `.?` consumes `\left.` / `\right.` which mean "no delimiter".
// Lookahead `(?![A-Za-z])` keeps `\leftarrow` / `\leftrightarrow` safe.
s = s.replace(/\\left(?![A-Za-z])\.?/g, '')
s = s.replace(/\\right(?![A-Za-z])\.?/g, '')
// Run symbol substitution BEFORE scripts so a body like `^{\infty}`
// becomes `^{∞}` first; convertScript can then either map ∞ to a
// superscript (it can't — Unicode lacks one) or fall back to `^∞`
// by stripping braces around the now-single-character body.
//
// Punctuation pass first — these can be followed by letters (`\{p`
// is "open-brace then p"), so the letter pass's `(?![A-Za-z])` rule
// would wrongly block them.
s = s.replace(SYMBOL_PUNCT_RE, m => SYMBOLS[m] ?? m)
s = s.replace(SYMBOL_LETTER_RE, m => SYMBOLS[m] ?? m)
// Bare `^c` / `_c` handles ONLY alphanumerics and `+`/`-`/`=`. Parens
// are intentionally excluded because the braced-fallback above can
// emit `(...)` and we don't want a second pass to greedily convert
// its opening paren into `⁽` and orphan the closing one.
s = s.replace(/\^\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUPERSCRIPT, '^'))
s = s.replace(/\^([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUPERSCRIPT[ch] ?? raw)
s = s.replace(/_\s*\{([^{}]+)\}/g, (_, body: string) => convertScript(body, SUBSCRIPT, '_'))
s = s.replace(/_([A-Za-z0-9+\-=])/g, (raw, ch: string) => SUBSCRIPT[ch] ?? raw)
return s
}
+168
View File
@@ -0,0 +1,168 @@
import { mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { performHeapDump } from './memory.js'
const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const
describe('performHeapDump auto opt-in gate (#21767)', () => {
let saved: Record<string, string | undefined>
let dir: string
beforeEach(() => {
saved = {}
for (const k of ENV_KEYS) {
saved[k] = process.env[k]
delete process.env[k]
}
dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-test-'))
process.env.HERMES_HEAPDUMP_DIR = dir
})
afterEach(() => {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) {
delete process.env[k]
} else {
process.env[k] = saved[k]
}
}
rmSync(dir, { force: true, recursive: true })
})
it('writes diagnostics only for auto-high without HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.diagPath).toBeDefined()
expect(result.heapPath).toBeUndefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.diagnostics.json'))).toBe(true)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)
})
it('writes diagnostics only for auto-critical without HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('auto-critical')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.heapPath).toBeUndefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false)
})
it('writes both diagnostics and snapshot for auto-high when HERMES_AUTO_HEAPDUMP=1', async () => {
process.env.HERMES_AUTO_HEAPDUMP = '1'
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBeUndefined()
expect(result.diagPath).toBeDefined()
expect(result.heapPath).toBeDefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)
})
it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => {
for (const value of ['true', 'YES', 'On']) {
process.env.HERMES_AUTO_HEAPDUMP = value
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.heapPath).toBeDefined()
}
})
it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => {
for (const value of ['0', 'off', 'nope']) {
process.env.HERMES_AUTO_HEAPDUMP = value
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
expect(result.suppressed).toBe(true)
expect(result.heapPath).toBeUndefined()
}
})
it('writes both for manual triggers regardless of HERMES_AUTO_HEAPDUMP', async () => {
const result = await performHeapDump('manual')
expect(result.success).toBe(true)
expect(result.suppressed).toBeUndefined()
expect(result.heapPath).toBeDefined()
const files = readdirSync(dir)
expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true)
})
})
describe('heapdump retention guard (#21767)', () => {
let savedDir: string | undefined
let savedMax: string | undefined
let dir: string
beforeEach(() => {
savedDir = process.env.HERMES_HEAPDUMP_DIR
savedMax = process.env.HERMES_HEAPDUMP_MAX_BYTES
delete process.env.HERMES_AUTO_HEAPDUMP
dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-prune-'))
process.env.HERMES_HEAPDUMP_DIR = dir
})
afterEach(() => {
if (savedDir === undefined) {
delete process.env.HERMES_HEAPDUMP_DIR
} else {
process.env.HERMES_HEAPDUMP_DIR = savedDir
}
if (savedMax === undefined) {
delete process.env.HERMES_HEAPDUMP_MAX_BYTES
} else {
process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax
}
rmSync(dir, { force: true, recursive: true })
})
it('evicts oldest files when total bytes exceed the cap, retaining the newest', async () => {
// 4 pre-existing dumps, 1KB each, with ascending mtimes (oldest first).
const blob = 'x'.repeat(1024)
const now = Date.now()
for (let i = 0; i < 4; i++) {
const p = join(dir, `old-${i}.heapsnapshot`)
writeFileSync(p, blob)
const t = (now - (4 - i) * 60_000) / 1000
utimesSync(p, t, t)
}
// Cap at 2KB → a fresh diagnostics write should trigger a prune down to ~cap.
process.env.HERMES_HEAPDUMP_MAX_BYTES = String(2 * 1024)
const result = await performHeapDump('auto-high')
expect(result.success).toBe(true)
const remaining = readdirSync(dir)
const totalBytes = remaining.reduce((acc, f) => acc + statSync(join(dir, f)).size, 0)
// Contract: prune evicts oldest-first until total <= cap, but always keeps
// the single newest file even if it alone exceeds the cap. So either the
// total is under cap, or exactly one (newest) file remains.
expect(totalBytes <= 2 * 1024 || remaining.length === 1).toBe(true)
// The old 1KB dumps must have been pruned down from the original four.
expect(remaining.length).toBeLessThan(5)
// The brand-new diagnostics sidecar must survive the prune.
expect(remaining.some(f => f.endsWith('.diagnostics.json'))).toBe(true)
})
})
+246
View File
@@ -0,0 +1,246 @@
import { createWriteStream } from 'node:fs'
import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { getHeapSnapshot, getHeapSpaceStatistics, getHeapStatistics } from 'node:v8'
export type MemoryTrigger = 'auto-critical' | 'auto-high' | 'manual'
export interface MemoryDiagnostics {
activeHandles: number
activeRequests: number
analysis: {
potentialLeaks: string[]
recommendation: string
}
memoryGrowthRate: {
bytesPerSecond: number
mbPerHour: number
}
memoryUsage: {
arrayBuffers: number
external: number
heapTotal: number
heapUsed: number
rss: number
}
nodeVersion: string
openFileDescriptors?: number
platform: string
resourceUsage: {
maxRSS: number
systemCPUTime: number
userCPUTime: number
}
smapsRollup?: string
timestamp: string
trigger: MemoryTrigger
uptimeSeconds: number
v8HeapSpaces?: { available: number; name: string; size: number; used: number }[]
v8HeapStats: {
detachedContexts: number
heapSizeLimit: number
mallocedMemory: number
nativeContexts: number
peakMallocedMemory: number
}
}
export interface HeapDumpResult {
diagPath?: string
error?: string
heapPath?: string
// True when an auto trigger wrote diagnostics only and intentionally skipped
// the heavy snapshot because HERMES_AUTO_HEAPDUMP was not enabled (#21767).
suppressed?: boolean
success: boolean
}
export async function captureMemoryDiagnostics(trigger: MemoryTrigger): Promise<MemoryDiagnostics> {
const usage = process.memoryUsage()
const heapStats = getHeapStatistics()
const resourceUsage = process.resourceUsage()
const uptimeSeconds = process.uptime()
// Not available on Bun / older Node.
let heapSpaces: ReturnType<typeof getHeapSpaceStatistics> | undefined
try {
heapSpaces = getHeapSpaceStatistics()
} catch {
/* noop */
}
const internals = process as unknown as {
_getActiveHandles: () => unknown[]
_getActiveRequests: () => unknown[]
}
const activeHandles = internals._getActiveHandles().length
const activeRequests = internals._getActiveRequests().length
const openFileDescriptors = await swallow(async () => (await readdir('/proc/self/fd')).length)
const smapsRollup = await swallow(() => readFile('/proc/self/smaps_rollup', 'utf8'))
const nativeMemory = usage.rss - usage.heapUsed
// Real growth rate since STARTED_AT (captured at module load) — NOT a lifetime
// average of rss/uptime, which would report phantom "growth" for a stable process.
const elapsed = Math.max(0, uptimeSeconds - STARTED_AT.uptime)
const bytesPerSecond = elapsed > 0 ? (usage.rss - STARTED_AT.rss) / elapsed : 0
const mbPerHour = (bytesPerSecond * 3600) / (1024 * 1024)
const potentialLeaks = [
heapStats.number_of_detached_contexts > 0 &&
`${heapStats.number_of_detached_contexts} detached context(s) — possible component/closure leak`,
activeHandles > 100 && `${activeHandles} active handles — possible timer/socket leak`,
nativeMemory > usage.heapUsed && 'Native memory > heap — leak may be in native addons',
mbPerHour > 100 && `High memory growth rate: ${mbPerHour.toFixed(1)} MB/hour`,
openFileDescriptors && openFileDescriptors > 500 && `${openFileDescriptors} open FDs — possible file/socket leak`
].filter((s): s is string => typeof s === 'string')
return {
activeHandles,
activeRequests,
analysis: {
potentialLeaks,
recommendation: potentialLeaks.length
? `WARNING: ${potentialLeaks.length} potential leak indicator(s). See potentialLeaks.`
: 'No obvious leak indicators. Inspect heap snapshot for retained objects.'
},
memoryGrowthRate: { bytesPerSecond, mbPerHour },
memoryUsage: {
arrayBuffers: usage.arrayBuffers,
external: usage.external,
heapTotal: usage.heapTotal,
heapUsed: usage.heapUsed,
rss: usage.rss
},
nodeVersion: process.version,
openFileDescriptors,
platform: process.platform,
resourceUsage: {
maxRSS: resourceUsage.maxRSS * 1024,
systemCPUTime: resourceUsage.systemCPUTime,
userCPUTime: resourceUsage.userCPUTime
},
smapsRollup,
timestamp: new Date().toISOString(),
trigger,
uptimeSeconds,
v8HeapSpaces: heapSpaces?.map(s => ({
available: s.space_available_size,
name: s.space_name,
size: s.space_size,
used: s.space_used_size
})),
v8HeapStats: {
detachedContexts: heapStats.number_of_detached_contexts,
heapSizeLimit: heapStats.heap_size_limit,
mallocedMemory: heapStats.malloced_memory,
nativeContexts: heapStats.number_of_native_contexts,
peakMallocedMemory: heapStats.peak_malloced_memory
}
}
}
export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promise<HeapDumpResult> {
try {
// Diagnostics first — heap-snapshot serialization can crash on very large
// heaps, and the JSON sidecar is the most actionable artifact if so.
const diagnostics = await captureMemoryDiagnostics(trigger)
const dir = process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps')
await mkdir(dir, { recursive: true })
const base = `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}`
const heapPath = join(dir, `${base}.heapsnapshot`)
const diagPath = join(dir, `${base}.diagnostics.json`)
// The diagnostics JSON is KB-sized and the most useful artifact when a
// full snapshot is suppressed by the auto-heapdump opt-in gate below.
await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 })
// Auto triggers require explicit opt-in: multi-GiB snapshots written on
// every threshold cross can fill the user's disk (issue #21767).
const isAuto = trigger === 'auto-critical' || trigger === 'auto-high'
const autoEnabled = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_AUTO_HEAPDUMP ?? '').trim())
if (isAuto && !autoEnabled) {
await pruneHeapdumps(dir).catch(() => undefined)
// Not an error: the dump did its job — it wrote the lightweight
// diagnostics sidecar and intentionally skipped the heavy snapshot.
// `heapPath` is omitted so callers/notices report diagnostics-only.
return { diagPath, suppressed: true, success: true }
}
await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 }))
await pruneHeapdumps(dir).catch(() => undefined)
return { diagPath, heapPath, success: true }
} catch (e) {
return { error: e instanceof Error ? e.message : String(e), success: false }
}
}
// Cap total bytes of files in `dir`, deleting oldest first. Covers both
// `.heapsnapshot` and `.diagnostics.json` artifacts so orphan sidecars from
// gated auto-triggers cannot accumulate without bound. The newest file is
// always retained even if it alone exceeds the cap.
async function pruneHeapdumps(dir: string): Promise<void> {
const raw = process.env.HERMES_HEAPDUMP_MAX_BYTES?.trim()
const parsed = raw ? Number(raw) : NaN
const cap = Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 ** 3
const names = await readdir(dir)
const stats = await Promise.all(
names.map(async name => {
const path = join(dir, name)
const s = await stat(path).catch(() => null)
return s && s.isFile() ? { mtimeMs: s.mtimeMs, path, size: s.size } : null
})
)
const valid = stats.filter((s): s is { mtimeMs: number; path: string; size: number } => s !== null)
valid.sort((a, b) => b.mtimeMs - a.mtimeMs)
let total = valid.reduce((acc, s) => acc + s.size, 0)
while (total > cap && valid.length > 1) {
const oldest = valid.pop()
if (!oldest) {
break
}
await unlink(oldest.path).catch(() => undefined)
total -= oldest.size
}
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0B'
}
const exp = Math.min(UNITS.length - 1, Math.floor(Math.log10(bytes) / 3))
const value = bytes / 1024 ** exp
return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)}${UNITS[exp]}`
}
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
const STARTED_AT = { rss: process.memoryUsage().rss, uptime: process.uptime() }
// Returns undefined when the probe isn't available (non-Linux paths, sandboxed FS).
const swallow = async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
try {
return await fn()
} catch {
return undefined
}
}
+188
View File
@@ -0,0 +1,188 @@
import { getHeapStatistics } from 'node:v8'
import { type HeapDumpResult, performHeapDump } from './memory.js'
export type MemoryLevel = 'critical' | 'high' | 'normal'
export interface MemorySnapshot {
heapUsed: number
level: MemoryLevel
rss: number
}
export interface MemoryMonitorOptions {
criticalBytes?: number
highBytes?: number
intervalMs?: number
onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
// Fired ONCE when heap growth looks abnormal while still far below the
// critical exit threshold — the regime where the TUI used to die silently
// (#34095: Node OOMs from an Ink render-tree blowup at a few hundred MB,
// well under criticalBytes, so onCritical never fired and the gateway death
// showed up only as a bare `stdin EOF`). A visible warning here makes that
// class of death diagnosable instead of silent.
onWarn?: (snap: MemorySnapshot) => void
warnBytes?: number
}
const GB = 1024 ** 3
const MB = 1024 ** 2
// Resolve the exit / dump thresholds RELATIVE to the actual V8 heap ceiling
// (--max-old-space-size, 8GB for the TUI) instead of hardcoding 2.5GB. The old
// constant killed the process — and silently closed the gateway's stdin — at
// ~31% of an 8GB ceiling, treating a normal long-session heap as an OOM. We now
// exit only when genuinely near the ceiling (critical ~88%, high ~70%), and
// clamp to sane floors/ceilings so a tiny --max-old-space-size can't drive the
// thresholds below the warn watermark. Callers may still override explicitly.
function resolveThresholds(criticalBytes?: number, highBytes?: number) {
let limit = 0
try {
limit = getHeapStatistics().heap_size_limit || 0
} catch {
limit = 0
}
// Fall back to the historical 8GB ceiling if V8 doesn't report one.
const ceiling = limit > 0 ? limit : 8 * GB
const critical = criticalBytes ?? Math.max(2 * GB, Math.round(ceiling * 0.88))
const high = highBytes ?? Math.max(1 * GB, Math.min(critical - 256 * MB, Math.round(ceiling * 0.7)))
return { critical, high }
}
// Deferred @hermes/ink import: loading `@hermes/ink` at module top-level
// pulls the full ~414KB Ink bundle (React, renderer, components, hooks) onto
// the critical path before the Python gateway can even be spawned. That
// serialised roughly 150ms of Node work in front of gw.start() on every
// cold `hermes --tui` launch.
//
// evictInkCaches only runs inside `tick()`, which fires on a 10s timer and
// only when heap pressure crosses the high-water mark — by then Ink has
// long since been loaded by the app entry. This dynamic import is a no-op
// on the hot path (module is already in the ESM cache); when a startup
// spike somehow trips the threshold before the app registers its own Ink
// import, we pay the load cost exactly once, inside the tick that needs it.
let _evictInkCaches: ((level: 'all' | 'half') => unknown) | null = null
let _evictInkCachesPromise: Promise<(level: 'all' | 'half') => unknown> | null = null
async function _ensureEvictInkCaches(): Promise<(level: 'all' | 'half') => unknown> {
if (_evictInkCaches) {
return _evictInkCaches
}
_evictInkCachesPromise ??= import('@hermes/ink')
.then(mod => {
_evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown
return _evictInkCaches
})
.catch(err => {
_evictInkCachesPromise = null
throw err
})
return _evictInkCachesPromise
}
export function startMemoryMonitor({
criticalBytes,
highBytes,
intervalMs = 10_000,
onCritical,
onHigh,
onWarn,
warnBytes = 600 * MB
}: MemoryMonitorOptions = {}): () => void {
const { critical, high } = resolveThresholds(criticalBytes, highBytes)
const dumped = new Set<Exclude<MemoryLevel, 'normal'>>()
const inFlight = new Set<Exclude<MemoryLevel, 'normal'>>()
// Early-warning state (#34095): the silent-death regime is BELOW `high`, so
// the level machine above never sees it. Track the previous sample and fire
// onWarn at most once when heap both crosses a modest absolute floor AND is
// climbing steeply (≥150MB between 10s ticks) — the signature of a render-
// tree blowup — so the user gets a visible heads-up before Node OOMs under
// the exit threshold. Re-armed only after heap falls back below the floor.
// `lastHeap < 0` marks the un-seeded first sample so a cold start that opens
// already-high can't be mistaken for sudden growth (growth = current - last).
let lastHeap = -1
let warned = false
const WARN_GROWTH_STEP = 150 * MB
// Cooldown prevents repeated auto dumps when heap oscillates around the
// threshold (issue #21767). `dumped` alone is not enough — it clears on
// every transition back to `normal`.
const cooldownRaw = process.env.HERMES_AUTO_HEAPDUMP_COOLDOWN_MS?.trim()
const cooldownParsed = cooldownRaw ? Number(cooldownRaw) : NaN
const cooldownMs = Number.isFinite(cooldownParsed) && cooldownParsed >= 0 ? cooldownParsed : 600_000
let lastAutoDumpAt = 0
const tick = async () => {
const { heapUsed, rss } = process.memoryUsage()
// Sub-threshold abnormal-growth warning. Skip on the first (un-seeded)
// sample — we need a prior reading to measure a delta against.
if (heapUsed < high && lastHeap >= 0) {
if (!warned && heapUsed >= warnBytes && heapUsed - lastHeap >= WARN_GROWTH_STEP) {
warned = true
onWarn?.({ heapUsed, level: 'normal', rss })
} else if (heapUsed < warnBytes) {
warned = false
}
}
lastHeap = heapUsed
const level: MemoryLevel = heapUsed >= critical ? 'critical' : heapUsed >= high ? 'high' : 'normal'
if (level === 'normal') {
dumped.clear()
return
}
if (dumped.has(level) || inFlight.has(level)) {
return
}
if (Date.now() - lastAutoDumpAt < cooldownMs) {
return
}
inFlight.add(level)
lastAutoDumpAt = Date.now()
// Prune Ink content caches before dump/exit — half on 'high' (recoverable),
// full on 'critical' (post-dump RSS reduction, keeps user running).
// Deferred import keeps `@hermes/ink` off the cold-start critical path;
// by the time a tick fires 10s after launch the app has already loaded
// the same module, so this resolves instantly from the ESM cache.
try {
try {
const evictInkCaches = await _ensureEvictInkCaches()
evictInkCaches(level === 'critical' ? 'all' : 'half')
} catch {
// Best-effort: if the dynamic import fails for any reason we still
// continue to the heap dump below so the user gets diagnostics.
}
dumped.add(level)
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
const snap: MemorySnapshot = { heapUsed, level, rss }
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
} finally {
inFlight.delete(level)
}
}
const handle = setInterval(() => void tick(), intervalMs)
handle.unref?.()
return () => clearInterval(handle)
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { appendTranscriptMessage } from './messages.js'
describe('appendTranscriptMessage', () => {
it('merges adjacent tool-only shelves into one transcript row', () => {
const out = appendTranscriptMessage([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }], {
kind: 'trail',
role: 'system',
text: '',
tools: ['Terminal("two") ✓']
})
expect(out).toEqual([
{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }
])
})
it('merges tool shelves into the nearest thinking shelf', () => {
const out = appendTranscriptMessage(
[{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓'] }],
{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] }
)
expect(out).toEqual([
{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }
])
})
})
+22
View File
@@ -0,0 +1,22 @@
import { MAX_HISTORY } from '../config/limits.js'
import type { Msg, Role } from '../types.js'
import { appendToolShelfMessage } from './liveProgress.js'
// Stamp live rows AT APPEND (wall clock, Unix seconds) rather than later:
// a message's authoring time is when it entered the transcript, not when it
// happened to be persisted or re-rendered (#82840-class rule). Rehydrated
// rows arrive with their persisted `createdAt` and keep it.
export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] =>
appendToolShelfMessage(prev, msg.createdAt === undefined ? { ...msg, createdAt: Date.now() / 1000 } : msg)
export const capTranscriptHistory = (items: Msg[]): Msg[] => {
if (items.length <= MAX_HISTORY) {
return items
}
return items[0]?.kind === 'intro' ? [items[0], ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY)
}
export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { fuzzyRank } from './fuzzy.js'
import { modelSearchText } from './model-search-text.js'
describe('modelSearchText', () => {
it('keeps ordinary model ids unchanged', () => {
expect(modelSearchText('kimi-k2.6')).toBe('kimi-k2.6')
expect(modelSearchText('glm-5.2')).toBe('glm-5.2')
})
it('adds kimi aliases for the bare Kimi Coding k3 wire id', () => {
expect(modelSearchText('k3')).toBe('k3 kimi-k3 kimi')
expect(modelSearchText('K3')).toBe('K3 kimi-k3 kimi')
})
it('adds ox-alpha aliases for the Ox Alpha preview wire id', () => {
expect(modelSearchText('x-preview-f-free')).toBe('x-preview-f-free ox-alpha ox')
expect(modelSearchText('X-Preview-F-Free')).toBe('X-Preview-F-Free ox-alpha ox')
})
})
describe('model picker search with aliases', () => {
const models = ['kimi-k2.6', 'kimi-k2.5', 'k3', 'kimi-for-coding']
it('surfaces k3 when the user searches kimi', () => {
const ranked = fuzzyRank(models, 'kimi', modelSearchText).map(r => r.item)
expect(ranked).toContain('k3')
})
it('still finds k3 by its wire id', () => {
const ranked = fuzzyRank(models, 'k3', modelSearchText).map(r => r.item)
expect(ranked).toEqual(['k3'])
})
it('does not invent k3 for unrelated queries', () => {
const ranked = fuzzyRank(models, 'glm', modelSearchText).map(r => r.item)
expect(ranked).toEqual([])
})
it('surfaces the Ox Alpha preview slug when the user searches ox', () => {
const zenModels = ['x-preview-f-free', 'gpt-5.6-sol', 'kimi-k3']
const ranked = fuzzyRank(zenModels, 'ox', modelSearchText).map(r => r.item)
expect(ranked).toContain('x-preview-f-free')
const rankedFull = fuzzyRank(zenModels, 'ox-alpha', modelSearchText).map(r => r.item)
expect(rankedFull).toContain('x-preview-f-free')
})
})
+33
View File
@@ -0,0 +1,33 @@
/**
* Extra tokens used only for model-picker search ranking.
*
* Wire IDs stay unchanged — some providers report short or brand-less ids
* (Kimi Coding's flagship is literally `k3`) that users still search for by
* the familiar `kimi-…` naming of sibling models.
*
* Keep in sync with web/src/lib/model-search-text.ts and
* hermes_cli/model_search.py.
*/
const MODEL_SEARCH_ALIASES: Record<string, readonly string[]> = {
k3: ['kimi-k3', 'kimi'],
// OpenCode Zen serves the "Ox Alpha" stealth model under an opaque
// preview slug; let users find it by its public codename.
'x-preview-f-free': ['ox-alpha', 'ox']
}
/** Haystack for fuzzy/substring model search; never changes the wire id. */
export function modelSearchText(model: string): string {
const id = model.trim()
if (!id) {
return model
}
const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]
if (!aliases?.length) {
return id
}
return `${id} ${aliases.join(' ')}`
}
+217
View File
@@ -0,0 +1,217 @@
import type { ChildProcess, spawn as SpawnFn } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { openCommand, openExternalUrl, parseSafeUrl } from './openExternalUrl.js'
type SpawnLike = typeof SpawnFn
describe('parseSafeUrl', () => {
it('accepts http and https URLs', () => {
expect(parseSafeUrl('https://example.com')?.href).toBe('https://example.com/')
expect(parseSafeUrl('http://example.com/path?q=1')?.href).toBe('http://example.com/path?q=1')
})
it('rejects file: URLs (would let a hostile model trigger arbitrary local handlers)', () => {
expect(parseSafeUrl('file:///etc/passwd')).toBeNull()
})
it('rejects javascript:, data:, and vbscript: URLs', () => {
expect(parseSafeUrl('javascript:alert(1)')).toBeNull()
expect(parseSafeUrl('data:text/html,<script>alert(1)</script>')).toBeNull()
expect(parseSafeUrl('vbscript:msgbox')).toBeNull()
})
it('rejects mailto:, ftp:, and other non-web protocols', () => {
expect(parseSafeUrl('mailto:test@example.com')).toBeNull()
expect(parseSafeUrl('ftp://example.com')).toBeNull()
expect(parseSafeUrl('ssh://example.com')).toBeNull()
})
it('rejects unparseable strings', () => {
expect(parseSafeUrl('not a url')).toBeNull()
expect(parseSafeUrl('')).toBeNull()
})
it('rejects non-string inputs defensively', () => {
expect(parseSafeUrl(undefined as unknown as string)).toBeNull()
expect(parseSafeUrl(null as unknown as string)).toBeNull()
expect(parseSafeUrl(123 as unknown as string)).toBeNull()
})
})
describe('openCommand', () => {
it('returns macOS open(1) on darwin', () => {
expect(openCommand('darwin')).toEqual({ command: 'open', args: [] })
})
it('routes through explorer.exe on win32 — not cmd.exe — so URLs with & | ^ < > stay safe', () => {
// win32 must not route through cmd.exe — see comment in openCommand.
// Test pins the contract that we use explorer.exe (non-shell) so URLs
// with `&`/`|`/`^`/`<`/`>` aren't reparsed by cmd's tokenizer.
const cmd = openCommand('win32')
expect(cmd?.command).toBe('explorer.exe')
expect(cmd?.args).toEqual([])
})
it('falls back to xdg-open on linux/bsd', () => {
expect(openCommand('linux')).toEqual({ command: 'xdg-open', args: [] })
expect(openCommand('freebsd')).toEqual({ command: 'xdg-open', args: [] })
expect(openCommand('openbsd')).toEqual({ command: 'xdg-open', args: [] })
})
it('returns null for unknown platforms (aix, sunos, cygwin, etc.)', () => {
// Avoid optimistically dispatching xdg-open on platforms where it
// probably isn't installed — the caller's `if (!command) return false`
// path surfaces "no opener" honestly instead.
expect(openCommand('aix')).toBeNull()
expect(openCommand('sunos')).toBeNull()
expect(openCommand('cygwin')).toBeNull()
expect(openCommand('haiku')).toBeNull()
expect(openCommand('')).toBeNull()
})
})
describe('openExternalUrl on unsupported platforms', () => {
it('returns false without spawning when the platform has no known opener', () => {
const spawn = vi.fn() as unknown as SpawnLike
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'aix' })).toBe(false)
expect(spawn).not.toHaveBeenCalled()
})
})
describe('openExternalUrl', () => {
// Tracks the most recent fake child so tests can inspect its 'error'
// handlers and emit on it. Use a loose EventEmitter alias rather than
// ChildProcess — the latter's `unref` signature is strictly `() => void`
// and doesn't accept `vi.fn()` without a generic.
type FakeChild = EventEmitter & { unref: () => void }
function mockSpawn(): {
spawn: SpawnLike
calls: Array<{ command: string; args: readonly string[] }>
lastChild: () => FakeChild | undefined
} {
const calls: Array<{ command: string; args: readonly string[] }> = []
let lastChild: FakeChild | undefined
const spawn = vi.fn((command: string, args: readonly string[]) => {
calls.push({ command, args })
// Use a real EventEmitter so .once('error', cb) wires up correctly
// and we can synthesize async failures by emitting 'error' from the
// test. The cast is the same one Node uses internally — ChildProcess
// extends EventEmitter.
const child = new EventEmitter() as FakeChild
child.unref = () => {}
lastChild = child
return child as unknown as ChildProcess
}) as unknown as SpawnLike
return { spawn, calls, lastChild: () => lastChild }
}
it('opens a normal https URL via the platform command', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('https://example.com/foo', { spawn, platform: () => 'darwin' })).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.command).toBe('open')
expect(calls[0]!.args).toEqual(['https://example.com/foo'])
})
it('uses xdg-open on linux', () => {
const { spawn, calls } = mockSpawn()
openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })
expect(calls[0]!.command).toBe('xdg-open')
})
it('refuses to open file: URLs and does not spawn', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('file:///etc/passwd', { spawn, platform: () => 'darwin' })).toBe(false)
expect(calls).toHaveLength(0)
})
it('refuses to open javascript: URLs and does not spawn', () => {
const { spawn, calls } = mockSpawn()
expect(openExternalUrl('javascript:alert(1)', { spawn, platform: () => 'darwin' })).toBe(false)
expect(calls).toHaveLength(0)
})
it('passes URLs containing shell metacharacters as plain args (no shell interpolation)', () => {
const { spawn, calls } = mockSpawn()
// A URL with `; & ` plus URL-encoded backticks. spawn(..., args) without
// shell:true means the OS receives these as a single argv element.
const hostile = 'https://example.com/path%3Bevil%20%26%20rm%20-rf'
openExternalUrl(hostile, { spawn, platform: () => 'darwin' })
expect(calls).toHaveLength(1)
expect(calls[0]!.args[calls[0]!.args.length - 1]).toBe(hostile)
})
it('on win32, a URL with & | ^ < > is forwarded as a single argv element via explorer.exe', () => {
const { spawn, calls } = mockSpawn()
// Plain http URL with & in query (very common, e.g. analytics params)
// plus other cmd metacharacters that would split or reinterpret the
// command if win32 routed through cmd.exe /c start. Note that the URL
// parser percent-encodes `<` and `>` (which is fine — encoded forms
// can't be reinterpreted by any shell), but `&`, `|`, `^` survive
// and would tokenize cmd.exe if we ever regressed back to it.
const meta = 'https://example.com/q?a=1&b=2|c^d<e>f'
expect(openExternalUrl(meta, { spawn, platform: () => 'win32' })).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.command).toBe('explorer.exe')
// The URL must arrive as exactly one argv element — not split on &/|/^/etc.
const forwarded = calls[0]!.args[0]!
expect(calls[0]!.args).toHaveLength(1)
expect(forwarded).toContain('a=1&b=2')
expect(forwarded).toContain('|c^d')
})
it('on win32, common http URLs with & query params are forwarded intact', () => {
const { spawn, calls } = mockSpawn()
const url = 'https://example.com/search?q=foo&page=2&utm_source=hermes'
openExternalUrl(url, { spawn, platform: () => 'win32' })
expect(calls[0]!.args).toEqual([url])
})
it('returns false on synchronous spawn failure', () => {
const spawn = vi.fn(() => {
throw new Error('ENOENT')
}) as unknown as SpawnLike
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(false)
})
it('does not crash the host when the spawned process emits an async error', () => {
// Real-world case: `xdg-open` / `explorer.exe` missing on PATH. spawn()
// returns a ChildProcess synchronously, then emits 'error' once the
// exec actually fails. Without a registered 'error' listener, Node
// re-throws the event as an uncaught exception → TUI dies. We attach
// a no-op listener inside openExternalUrl; this test pins that contract.
const { spawn, lastChild } = mockSpawn()
expect(openExternalUrl('https://example.com/', { spawn, platform: () => 'linux' })).toBe(true)
const child = lastChild()
expect(child).toBeDefined()
// Must have a listener registered BEFORE we emit, or EventEmitter will
// throw synchronously here (which is exactly the crash we're preventing).
expect(child!.listenerCount('error')).toBeGreaterThan(0)
// Emit and assert it doesn't throw. If the listener weren't attached,
// this would throw 'Unhandled error' and fail the test.
expect(() => child!.emit('error', new Error('ENOENT: xdg-open not found'))).not.toThrow()
})
})
+158
View File
@@ -0,0 +1,158 @@
import { spawn, type SpawnOptions } from 'node:child_process'
import { platform } from 'node:os'
/**
* Opens an external URL in the user's default browser/handler.
*
* Wired into the Ink instance via `onHyperlinkClick` in entry.tsx, so any
* mouse click on a `<Link>` cell (or a row containing a plain-text URL the
* renderer detected) goes here. Mouse tracking inside the TUI prevents
* Terminal.app's native Cmd+click from firing — the click is captured
* before the terminal application sees it — so we have to handle the open
* ourselves.
*
* Safety:
* - http(s) only. Anything else (`file:`, `data:`, `javascript:`, etc.) is
* rejected — a hostile model could otherwise emit `<Link url="file:///">`
* and trick a click into running an arbitrary local handler.
* - Hostname is parsed via `URL`; only well-formed URLs are forwarded.
* - Spawned via `child_process.spawn` with arg array (no shell), so a URL
* containing shell metacharacters (`;`, `&`, backticks) cannot be
* interpreted as a command.
*
* Returns `true` if the spawn was attempted, `false` if the open could
* not proceed — covers (a) URL rejected by `parseSafeUrl` (non-http(s),
* malformed, etc.), (b) no known opener for the current platform
* (`openCommand` returned null), or (c) `spawn()` threw synchronously
* before the child was created. Async failures after spawn (`'error'`
* event because the binary couldn't exec) still return `true` because
* the spawn was attempted — the no-op error listener absorbs the event
* so the TUI doesn't crash, and the user just doesn't see their browser
* pop.
*/
export function openExternalUrl(rawUrl: string, dependencies: OpenDependencies = {}): boolean {
const url = parseSafeUrl(rawUrl)
if (!url) {
return false
}
const spawnFn = dependencies.spawn ?? spawn
const platformId = dependencies.platform?.() ?? platform()
const command = openCommand(platformId)
if (!command) {
return false
}
try {
const child = spawnFn(command.command, [...command.args, url.toString()], {
// Detach so closing the TUI later doesn't kill the browser process,
// and ignore stdio so we don't leak FDs into our raw-mode terminal.
// Without `ignore` here, Chrome's stderr can land in the alt screen.
detached: true,
stdio: 'ignore'
} satisfies SpawnOptions)
// Async failure path: spawn returns a ChildProcess synchronously even
// when the binary is missing (ENOENT on `xdg-open` / `explorer.exe`),
// unreachable (EACCES), or otherwise unusable — the failure surfaces
// later as an 'error' event. Without a handler, an unhandled 'error'
// on an EventEmitter crashes Node, which would tear down the whole
// TUI. Attach a no-op listener BEFORE unref() so the event has a
// consumer; we already returned `true` synchronously, so the user
// just won't see their browser open — same as if the URL had been
// rejected upstream.
child.once('error', () => {
// Intentional no-op. The TUI keeps running; user gets no browser
// pop, which is the failure mode we promised in the doc comment.
})
child.unref()
return true
} catch {
// spawn can also throw synchronously on argv-validation failures
// (e.g. NUL in the path). Treat it as a no-op rather than crashing.
return false
}
}
export type OpenDependencies = {
spawn?: typeof spawn
platform?: () => string
}
/**
* Validate and normalize a URL for opening externally.
* Exported for testing.
*/
export function parseSafeUrl(value: string): null | URL {
if (!value || typeof value !== 'string') {
return null
}
let parsed: URL
try {
parsed = new URL(value)
} catch {
return null
}
// http(s) only — opening file://, data:, javascript:, vbscript:, etc.
// would let a malicious model run a local handler with attacker-controlled
// input on a single click.
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
// Reject empty or all-whitespace hostnames defensively. URL parsing
// accepts URLs like 'http:///foo' on some Node versions; we don't want
// to forward those to `open`.
if (!parsed.hostname.trim()) {
return null
}
return parsed
}
type OpenCommand = { command: string; args: readonly string[] }
/**
* Per-platform open command. We deliberately avoid `cmd.exe /c start` on
* Windows even though it's the canonical example, because `start` is a cmd
* builtin: the URL string is reparsed by cmd's command-line tokenizer and
* characters like `&`, `|`, `^`, `<`, `>` either break the command or get
* interpreted as additional commands. That undermines the protocol
* allowlist's safety story and also breaks plain http(s) URLs with `&` in
* query strings. `explorer.exe <url>` is the safe, non-shell alternative —
* it invokes the registered protocol handler for http(s) without going
* through cmd. Linux/BSD use `xdg-open` directly with no shell wrapping.
*
* Returns null for platforms where we don't know a safe opener (e.g. `aix`,
* `sunos`, `cygwin`). The caller's `if (!command) return false` path then
* surfaces "no opener" instead of optimistically trying `xdg-open` on a
* platform that probably doesn't have it.
*/
export function openCommand(platformId: string): OpenCommand | null {
if (platformId === 'darwin') {
return { command: 'open', args: [] }
}
if (platformId === 'win32') {
return { command: 'explorer.exe', args: [] }
}
// Linux + the BSD family ship xdg-open via xdg-utils. Everything else
// (aix, sunos, cygwin, haiku, etc.) returns null so openExternalUrl's
// command-not-found fallback fires honestly.
const XDG_OPEN_PLATFORMS = new Set(['linux', 'freebsd', 'openbsd', 'netbsd', 'dragonfly'])
if (XDG_OPEN_PLATFORMS.has(platformId)) {
return { command: 'xdg-open', args: [] }
}
return null
}
+76
View File
@@ -0,0 +1,76 @@
const ESC = '\x1b'
const BEL = '\x07'
const ST = `${ESC}\\`
export const OSC52_CLIPBOARD_QUERY = `${ESC}]52;c;?${BEL}`
type OscResponse = { code: number; data: string; type: 'osc' }
type OscQuerier = {
flush: () => Promise<void>
send: <T>(query: { match: (r: unknown) => r is T; request: string }) => Promise<T | undefined>
}
function wrapForMultiplexer(sequence: string): string {
if (process.env['TMUX']) {
return `${ESC}Ptmux;${sequence.split(ESC).join(ESC + ESC)}${ST}`
}
if (process.env['STY']) {
return `${ESC}P${sequence}${ST}`
}
return sequence
}
export function buildOsc52ClipboardQuery(): string {
return wrapForMultiplexer(OSC52_CLIPBOARD_QUERY)
}
export function parseOsc52ClipboardData(data: string): null | string {
const firstSep = data.indexOf(';')
if (firstSep === -1) {
return null
}
const selection = data.slice(0, firstSep)
const payload = data.slice(firstSep + 1)
if ((selection !== 'c' && selection !== 'p') || !payload || payload === '?') {
return null
}
try {
return Buffer.from(payload, 'base64').toString('utf8')
} catch {
return null
}
}
export async function readOsc52Clipboard(querier: null | OscQuerier, timeoutMs = 500): Promise<null | string> {
if (!querier) {
return null
}
const timeout = new Promise<void>(resolve => setTimeout(resolve, timeoutMs))
const query = querier.send<OscResponse>({
request: buildOsc52ClipboardQuery(),
match: (r: unknown): r is OscResponse => {
return !!r && typeof r === 'object' && (r as OscResponse).type === 'osc' && (r as OscResponse).code === 52
}
})
const response = await Promise.race([query, timeout])
await querier.flush()
return response ? parseOsc52ClipboardData(response.data) : null
}
export const writeOsc52Clipboard = (s: string) => {
const sequence = `${ESC}]52;c;${Buffer.from(s, 'utf8').toString('base64')}${BEL}`
return process.stdout.write(wrapForMultiplexer(sequence))
}
+59
View File
@@ -0,0 +1,59 @@
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
// Mirror the Python gateway's panic log (tui_gateway/server.py::_CRASH_LOG) from
// the Node parent so lifecycle breadcrumbs interleave, by timestamp, with the
// child's `=== SIGTERM received ===` / `=== gateway exit ===` entries.
//
// A backend SIGTERM is *usually* a parent action — `gw.kill()` (graceful-exit on
// a signal to Node, or an explicit /quit) or `start()` replacing a live child —
// but it can also come straight from an external supervisor (s6, a cgroup OOM
// reaper, a stray `kill`) signalling the child directly. Telling those apart is
// exactly the point: #31051 left these breadcrumbs in an in-memory CircularBuffer
// that dies with the process, so SIGTERM crash reports arrived with no parent
// context. A `[tui-parent]` line immediately before the child's panic means a
// parent kill; its absence *suggests* an external signal — not definitive,
// since this logger is best-effort (disabled under VITEST, and a failed append
// is swallowed). Persisting the death-explaining events here is what makes that
// distinction (and a memory-critical `process.exit(137)`, which closes stdin →
// clean EOF, not SIGTERM) diagnosable after the fact.
const logDir = join(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes'), 'logs')
const CRASH_LOG = join(logDir, 'tui_gateway_crash.log')
// Skipped under vitest so unit tests exercising start()/kill() can't write into
// a real ~/.hermes (tests must stay hermetic — see AGENTS.md).
const enabled = !process.env.VITEST
// Slice a single breadcrumb's value to MAX_BREADCRUMB chars (a short
// "[truncated …]" marker is appended, so the written line is slightly longer)
// so a pathological value (e.g. a giant error) can't bloat the shared crash log
// or add noticeable blocking on the synchronous append. Mirrors the spirit of
// GatewayClient's in-memory log-line cap.
const MAX_BREADCRUMB = 4096
let warned = false
export function recordParentLifecycle(line: string): void {
if (!enabled) {
return
}
try {
// Collapse embedded newlines so a multi-line value (e.g. an error message)
// stays one breadcrumb and can't masquerade as a separate log entry or as
// the child's panic output sharing this file.
const oneLine = line.replace(/[\r\n]+/g, ' ↵ ')
const capped =
oneLine.length > MAX_BREADCRUMB
? `${oneLine.slice(0, MAX_BREADCRUMB)}… [truncated ${oneLine.length} chars]`
: oneLine
mkdirSync(logDir, { recursive: true })
appendFileSync(CRASH_LOG, `[tui-parent] ${new Date().toISOString()} ${capped}\n`)
} catch {
if (!warned) {
warned = true
process.stderr.write('hermes-tui: parent lifecycle log unavailable\n')
}
}
}
+107
View File
@@ -0,0 +1,107 @@
// Perf instrumentation for the full render pipeline.
//
// PerfPane (React.Profiler) → per-pane commit times
// logFrameEvent (ink.onFrame) → yoga / renderer / diff / optimize / write
// phases + yoga counters + scroll fast-path
//
// Both gate on HERMES_DEV_PERF=1 and dump JSON-lines (default ~/.hermes/perf.log,
// override HERMES_DEV_PERF_LOG). Tagged { src: 'react' | 'frame' } for jq.
// HERMES_DEV_PERF_MS (default 2) skips sub-ms idle frames; set 0 to capture all.
//
// Zero cost when unset: PerfPane returns children directly, logFrameEvent is
// undefined so ink doesn't pay the timing cost.
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { FrameEvent } from '@hermes/ink'
import { scrollFastPathStats } from '@hermes/ink'
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
const ENABLED = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_DEV_PERF ?? '').trim())
const THRESHOLD_MS = Number(process.env.HERMES_DEV_PERF_MS ?? '2') || 0
const LOG_PATH = process.env.HERMES_DEV_PERF_LOG?.trim() || join(homedir(), '.hermes', 'perf.log')
let logReady = false
const writeRow = (row: Record<string, unknown>) => {
if (!logReady) {
logReady = true
try {
mkdirSync(dirname(LOG_PATH), { recursive: true })
} catch {
// Best-effort — never crash the TUI to log a sample.
}
}
try {
appendFileSync(LOG_PATH, `${JSON.stringify(row)}\n`)
} catch {
/* best-effort */
}
}
const round2 = (n: number) => Math.round(n * 100) / 100
const onRender: ProfilerOnRenderCallback = (id, phase, actualMs, baseMs, startTime, commitTime) => {
if (actualMs < THRESHOLD_MS) {
return
}
writeRow({
actualMs: round2(actualMs),
baseMs: round2(baseMs),
commitTimeMs: round2(commitTime),
id,
phase,
src: 'react',
startTimeMs: round2(startTime),
ts: Date.now()
})
}
export function PerfPane({ children, id }: { children: ReactNode; id: string }) {
if (!ENABLED) {
return children
}
return (
<Profiler id={id} onRender={onRender}>
{children}
</Profiler>
)
}
export const logFrameEvent = ENABLED
? (event: FrameEvent) => {
if (event.durationMs < THRESHOLD_MS) {
return
}
writeRow({
durationMs: round2(event.durationMs),
// Cumulative counters — consumers diff pairs to get per-frame deltas.
fastPath: { ...scrollFastPathStats, declined: { ...scrollFastPathStats.declined } },
flickers: event.flickers.length ? event.flickers : undefined,
phases: event.phases
? {
...event.phases,
commit: round2(event.phases.commit),
diff: round2(event.phases.diff),
optimize: round2(event.phases.optimize),
prevFrameDrainMs: round2(event.phases.prevFrameDrainMs),
renderer: round2(event.phases.renderer),
write: round2(event.phases.write),
yoga: round2(event.phases.yoga)
}
: undefined,
src: 'frame',
ts: Date.now()
})
}
: undefined
export const PERF_ENABLED = ENABLED
export const PERF_LOG_PATH = LOG_PATH
+73
View File
@@ -0,0 +1,73 @@
import type { GatewayClient } from '../gatewayClient.js'
import { asRpcResult } from './rpc.js'
export interface PetMetaResult {
enabled?: boolean
scale?: number
slug?: string
spritesheetRevision?: string
}
interface PetUpdate<TCells> {
cells: TCells | null
meta: PetMetaResult
}
type PetGateway = Pick<GatewayClient, 'request'>
/**
* Suppress overlapping cosmetic polls so a slow gateway can never accumulate
* a queue of pet requests. Returning false tells callers that an existing
* probe is still in flight.
*/
export function createPetSingleFlight() {
let active = false
return async (operation: () => Promise<void>): Promise<boolean> => {
if (active) {
return false
}
active = true
try {
await operation()
return true
} finally {
active = false
}
}
}
/**
* Probe cheap pet metadata on the gateway reader thread, then request the
* expensive frame payload only when the active selection/state is not cached.
* This deliberately bypasses the transcript-logging RPC wrapper: pet display
* is cosmetic, so an unavailable gateway must not print an error.
*/
export async function requestPetUpdate<TCells>(
gateway: PetGateway,
state: string,
graphics: boolean,
needsCells: (meta: PetMetaResult) => boolean
): Promise<PetUpdate<TCells> | null> {
try {
const meta = asRpcResult(await gateway.request('pet.info.meta')) as PetMetaResult | null
if (!meta) {
return null
}
if (!meta.enabled || !needsCells(meta)) {
return { cells: null, meta }
}
const cells = asRpcResult(await gateway.request('pet.cells', { graphics, state })) as TCells | null
return { cells, meta }
} catch {
return null
}
}
+414
View File
@@ -0,0 +1,414 @@
/** Platform-aware keybinding helpers.
*
* On macOS the "action" modifier is Cmd. Modern terminals that support kitty
* keyboard protocol report Cmd as `key.super`; legacy terminals often surface it
* as `key.meta`. Some macOS terminals also translate Cmd+Left/Right/Backspace
* into readline-style Ctrl+A/Ctrl+E/Ctrl+U before the app sees them.
* On other platforms the action modifier is Ctrl.
* Ctrl+C stays the interrupt key on macOS. On non-mac terminals it can also
* copy an active TUI selection, matching common terminal selection behavior.
*/
export const isMac = process.platform === 'darwin'
/** True when the platform action-modifier is pressed (Cmd on macOS, Ctrl elsewhere). */
export const isActionMod = (key: { ctrl: boolean; meta: boolean; super?: boolean }): boolean =>
isMac ? key.meta || key.super === true : key.ctrl
/**
* Accept raw Ctrl+<letter> as an action shortcut on macOS, where `isActionMod`
* otherwise means Cmd. Two motivations:
* - Some macOS terminals rewrite Cmd navigation/deletion into readline control
* keys (Cmd+Left → Ctrl+A, Cmd+Right → Ctrl+E, Cmd+Backspace → Ctrl+U).
* - Ctrl+K (kill-to-end) and Ctrl+W (delete-word-back) are standard readline
* bindings that users expect to work regardless of platform, even though
* no terminal rewrites Cmd into them.
*/
export const isMacActionFallback = (
key: { ctrl: boolean; meta: boolean; super?: boolean },
ch: string,
target: 'a' | 'e' | 'u' | 'k' | 'w'
): boolean => isMac && key.ctrl && !key.meta && key.super !== true && ch.toLowerCase() === target
/** Match action-modifier + a single character (case-insensitive). */
export const isAction = (key: { ctrl: boolean; meta: boolean; super?: boolean }, ch: string, target: string): boolean =>
isActionMod(key) && ch.toLowerCase() === target
export const isRemoteShell = (env: NodeJS.ProcessEnv = process.env): boolean =>
Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
export const isCopyShortcut = (
key: { ctrl: boolean; meta: boolean; super?: boolean },
ch: string,
env: NodeJS.ProcessEnv = process.env
): boolean =>
ch.toLowerCase() === 'c' &&
(isAction(key, ch, 'c') ||
(isRemoteShell(env) && (key.meta || key.super === true)) ||
// VS Code/Cursor/Windsurf terminal setup forwards Cmd+C as a CSI-u
// sequence with the super bit plus a benign ctrl bit. Accept that shape
// even though raw Ctrl+C should remain interrupt on local macOS.
(isMac && key.ctrl && (key.meta || key.super === true)))
/**
* Voice recording toggle key — configurable via ``voice.record_key`` in
* ``config.yaml`` (default ``ctrl+b``).
*
* Documented in tips.py, the Python CLI prompt_toolkit handler, and the
* config.yaml default. The TUI honours the same config knob (#18994);
* when ``voice.record_key`` is e.g. ``ctrl+o`` the TUI binds Ctrl+O.
*
* Only the documented default (``ctrl+b``) additionally accepts the
* macOS action modifier (Cmd+B) — custom bindings like ``ctrl+o``
* require the literal Ctrl bit so Cmd+O can't steal the shortcut.
*/
export type VoiceRecordKeyMod = 'alt' | 'ctrl' | 'super'
/** Named (multi-character) keys we support, matching the CLI's
* prompt_toolkit binding shape (``c-space``, ``c-enter``, etc.) so a
* config value like ``ctrl+space`` binds in both runtimes. */
export type VoiceRecordKeyNamed = 'backspace' | 'delete' | 'enter' | 'escape' | 'space' | 'tab'
export interface ParsedVoiceRecordKey {
/** Single character (``'b'``, ``'o'``) when ``named`` is undefined,
* otherwise the named-key token (``'space'``, ``'enter'``…). Kept as
* one field for back-compat with the v1 ``{ ch, mod, raw }`` shape. */
ch: string
mod: VoiceRecordKeyMod
named?: VoiceRecordKeyNamed
raw: string
}
export const DEFAULT_VOICE_RECORD_KEY: ParsedVoiceRecordKey = {
ch: 'b',
mod: 'ctrl',
raw: 'ctrl+b'
}
/** Modifier aliases.
*
* ``meta`` / ``cmd`` / ``command`` are intentionally absent.
* hermes-ink sets ``key.meta`` for plain Alt/Option on every platform
* AND for Cmd on some legacy macOS terminals (Terminal.app without
* kitty-protocol passthrough). Accepting any of those as a literal
* modifier would produce a display/binding mismatch — a config like
* ``cmd+b`` would render as ``Cmd+B`` but silently fire on Alt+B, or
* never fire at all on legacy terminals even though the UI advertises
* it (Copilot round-6 review on #19835). Users on modern kitty-style
* terminals (iTerm2 CSI-u, Ghostty, Kitty, WezTerm, Alacritty) spell
* the platform action modifier ``super`` / ``win``, which match the
* unambiguous ``key.super`` bit. macOS users on Terminal.app stick
* with the documented ``ctrl+b``.
*
* Cross-runtime parity: the ``ctrl`` / ``control`` / ``alt`` / ``option`` /
* ``opt`` spellings are normalized identically in the classic CLI
* (``hermes_cli/voice.py::normalize_voice_record_key_for_prompt_toolkit``)
* so one ``voice.record_key`` value binds the same shortcut in both
* runtimes (Copilot round-9 review on #19835). The ``super`` /
* ``win`` / ``windows`` spellings are TUI-only — prompt_toolkit has no
* super modifier, so the CLI falls back to the documented default and
* logs a warning at startup (Copilot round-11 review on #19835). */
const _MOD_ALIASES: Record<string, VoiceRecordKeyMod> = {
alt: 'alt',
control: 'ctrl',
ctrl: 'ctrl',
option: 'alt',
opt: 'alt',
super: 'super',
win: 'super',
windows: 'super'
}
/** Map config-string named tokens to the canonical name used at match time.
*
* Aliases mirror what prompt_toolkit accepts (``return`` ↔ ``enter``,
* ``esc`` ↔ ``escape``) so a config that round-trips through the CLI also
* binds in the TUI. */
const _NAMED_KEY_ALIASES: Record<string, VoiceRecordKeyNamed> = {
backspace: 'backspace',
bs: 'backspace',
del: 'delete',
delete: 'delete',
enter: 'enter',
esc: 'escape',
escape: 'escape',
ret: 'enter',
return: 'enter',
space: 'space',
spc: 'space',
tab: 'tab'
}
/** ``useInputHandlers()`` intercepts these unconditionally before the
* voice check runs, so a binding like ``ctrl+c`` (interrupt),
* ``ctrl+d`` (quit), or ``ctrl+l`` (clear screen) would be advertised
* in /voice status but never fire push-to-talk. Reject at parse time
* so the user gets the documented Ctrl+B instead of a dead shortcut
* (Copilot round-4 review on #19835).
*
* ``ctrl+x`` is intentionally NOT here — it's only claimed during
* queue-edit (``queueEditIdx !== null``), so the voice binding works
* for most of the session and matches CLI parity for ``ctrl+<letter>``
* bindings (Copilot round-8 review on #19835). */
const _RESERVED_CTRL_CHARS = new Set(['c', 'd', 'l'])
/** On macOS the action-modifier intercepts these editor chords via
* ``isCopyShortcut`` / ``isAction`` in ``useInputHandlers()``:
* - super+c → copy
* - super+d → exit
* - super+l → clear screen
* - super+v → paste (also claimed at the TextInput layer)
* On Linux/Windows those globals key off Ctrl instead of Super, so
* super+<letter> bindings don't collide. Gate the rejection to darwin
* at parse time so kitty/CSI-u ``super+<key>`` configs still work for
* non-mac users (Copilot round-8 review on #19835). */
const _RESERVED_SUPER_CHARS = new Set(['c', 'd', 'l', 'v'])
/** On macOS ``isActionMod`` accepts ``key.meta`` as the action
* modifier — but hermes-ink reports Alt as ``key.meta`` on many
* terminals. So on darwin a configured ``alt+c`` / ``alt+d`` / ``alt+l``
* gets swallowed by ``isCopyShortcut`` / ``isAction`` before the voice
* check runs. Block at parse time so /voice status doesn't advertise
* a shortcut that actually copies / quits / clears (Copilot round-12
* review on #19835). */
const _RESERVED_ALT_CHARS_MAC = new Set(['c', 'd', 'l'])
interface RuntimeKeyEvent {
alt?: boolean
backspace?: boolean
ctrl: boolean
delete?: boolean
escape?: boolean
meta: boolean
return?: boolean
shift?: boolean
super?: boolean
tab?: boolean
}
/** Match an ink ``key`` event against a parsed named key. The ink runtime
* sets one boolean per named key; ``space`` is a printable char so it
* arrives as ``ch === ' '`` rather than a dedicated ``key.space`` flag. */
const _matchesNamedKey = (named: VoiceRecordKeyNamed, key: RuntimeKeyEvent, ch: string): boolean => {
switch (named) {
case 'backspace':
return key.backspace === true
case 'delete':
return key.delete === true
case 'enter':
return key.return === true
case 'escape':
return key.escape === true
case 'space':
return ch === ' '
case 'tab':
return key.tab === true
}
}
/**
* Parse a config-string voice record key like ``ctrl+b`` / ``alt+r`` /
* ``ctrl+space`` into ``{mod, ch, named?}``. Accepts single characters
* AND the named tokens declared in ``_NAMED_KEY_ALIASES`` (``space``,
* ``enter``/``return``, ``tab``, ``escape``/``esc``, ``backspace``,
* ``delete``) — matching the keys prompt_toolkit accepts on the CLI
* side via the ``c-<name>`` rewrite in ``cli.py``.
*
* Accepts ``unknown`` because the source is raw YAML via
* ``config.get full`` — a hand-edited ``voice.record_key: 1`` or
* ``voice.record_key: true`` would otherwise crash ``.trim()`` on a
* non-string scalar (Copilot round-3 review on #19835). Non-string /
* empty / unrecognised values fall back to the documented Ctrl+B
* default so a typo never silently disables the shortcut.
*/
export const parseVoiceRecordKey = (raw: unknown): ParsedVoiceRecordKey => {
if (typeof raw !== 'string') {
return DEFAULT_VOICE_RECORD_KEY
}
const lower = raw.trim().toLowerCase()
if (!lower) {
return DEFAULT_VOICE_RECORD_KEY
}
const parts = lower
.split('+')
.map(p => p.trim())
.filter(Boolean)
if (!parts.length) {
return DEFAULT_VOICE_RECORD_KEY
}
const last = parts[parts.length - 1]
const modCandidates = parts.slice(0, -1)
// Reject multi-modifier chords (``ctrl+alt+r``, ``cmd+ctrl+b``) rather
// than silently dropping the extra modifier — the previous
// single-token validator made a typo bind a different shortcut than
// the user configured (Copilot round-3 review on #19835). The classic
// CLI only supports single-modifier bindings via prompt_toolkit's
// ``c-x`` / ``a-x`` rewrite in ``cli.py``, so this matches CLI parity.
if (modCandidates.length > 1) {
return DEFAULT_VOICE_RECORD_KEY
}
// Require an explicit modifier. A bare ``o`` / ``space`` / ``escape``
// has no sensible mapping: the CLI's prompt_toolkit binds the raw
// key (no rewrite) so bare-char configs would silently diverge
// between the two runtimes (Copilot round-4 review on #19835).
// Fall back to the documented default.
if (modCandidates.length === 0) {
return DEFAULT_VOICE_RECORD_KEY
}
const norm = _MOD_ALIASES[modCandidates[0]]
// Unknown modifier token (e.g. bare ``meta+b`` which is ambiguous on
// the wire) falls back to the documented default rather than
// silently coercing to Ctrl and producing a misleading bind.
if (!norm) {
return DEFAULT_VOICE_RECORD_KEY
}
const mod = norm
// Block bindings the TUI input handler intercepts before the voice
// check — ``ctrl+c`` / ``ctrl+d`` / ``ctrl+l`` would never actually
// fire push-to-talk, so advertising them in /voice status is a lie.
if (mod === 'ctrl' && last.length === 1 && _RESERVED_CTRL_CHARS.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
// Same for ``super+c`` / ``super+d`` / ``super+l`` / ``super+v`` on
// macOS only — those are copy / exit / clear / paste and get claimed
// by ``isCopyShortcut`` / ``isAction`` / the TextInput paste layer
// before voice has a chance to toggle. On Linux/Windows the TUI
// globals key off Ctrl (not Super), so kitty/CSI-u ``super+<letter>``
// bindings stay usable for non-mac users.
if (isMac && mod === 'super' && last.length === 1 && _RESERVED_SUPER_CHARS.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
// On macOS hermes-ink reports Alt as ``key.meta``, which ``isActionMod``
// accepts as the mac action modifier. So ``alt+c`` / ``alt+d`` / ``alt+l``
// collide with copy / exit / clear in ``useInputHandlers()`` before the
// voice check. Reject at parse time on darwin only — non-mac ``alt+<letter>``
// bindings are still usable (Copilot round-12 review on #19835).
if (isMac && mod === 'alt' && last.length === 1 && _RESERVED_ALT_CHARS_MAC.has(last)) {
return DEFAULT_VOICE_RECORD_KEY
}
if (last.length === 1) {
return { ch: last, mod, raw: lower }
}
const named = _NAMED_KEY_ALIASES[last]
if (named) {
return { ch: named, mod, named, raw: lower }
}
// Unknown multi-character token (e.g. typo'd ``ctrl+spcae``) — fall back
// to the doc default rather than silently disabling the binding.
return DEFAULT_VOICE_RECORD_KEY
}
/** Render a parsed key back as ``Ctrl+B`` / ``Ctrl+Space`` for status text.
*
* Platform-aware for the ``super`` modifier: renders ``Cmd`` on macOS and
* ``Super`` elsewhere. Previously rendered ``Cmd`` universally, which told
* Linux/Windows users the wrong modifier to press (Copilot review, round
* 2 on #19835). */
export const formatVoiceRecordKey = (parsed: ParsedVoiceRecordKey): string => {
const modLabel =
parsed.mod === 'super' ? (isMac ? 'Cmd' : 'Super') : parsed.mod[0].toUpperCase() + parsed.mod.slice(1)
// Named tokens render in title case (Ctrl+Space, Ctrl+Enter); single
// chars render upper-case to match the existing Ctrl+B convention.
const keyLabel = parsed.named ? parsed.named[0].toUpperCase() + parsed.named.slice(1) : parsed.ch.toUpperCase()
return `${modLabel}+${keyLabel}`
}
/** Whether the parsed binding is the documented default (ctrl+b).
*
* Compare on the parsed spec rather than ``raw`` so semantically-equal
* aliases (``control+b``, ``ctrl + b``) still get the macOS Cmd+B
* muscle-memory fallback (Copilot review, round 2 on #19835). */
const _isDefaultVoiceKey = (parsed: ParsedVoiceRecordKey): boolean =>
parsed.mod === DEFAULT_VOICE_RECORD_KEY.mod &&
parsed.ch === DEFAULT_VOICE_RECORD_KEY.ch &&
parsed.named === DEFAULT_VOICE_RECORD_KEY.named
export const isVoiceToggleKey = (
key: RuntimeKeyEvent,
ch: string,
configured: ParsedVoiceRecordKey = DEFAULT_VOICE_RECORD_KEY
): boolean => {
// Match the configured key first (single-char compare or named-key
// event-property check). Bail out before evaluating modifier shape
// so the wrong key never reaches the modifier guard.
if (configured.named) {
if (!_matchesNamedKey(configured.named, key, ch)) {
return false
}
} else if (ch.toLowerCase() !== configured.ch) {
return false
}
// The parser rejects multi-modifier configs (``ctrl+shift+b`` etc.),
// so at match time Shift must always be clear — otherwise
// ``ctrl+tab`` would also fire on Ctrl+Shift+Tab and ``alt+enter``
// on Alt+Shift+Enter, triggering a different chord than configured
// (Copilot round-5 review on #19835).
if (key.shift === true) {
return false
}
switch (configured.mod) {
case 'alt':
// Most terminals surface Alt as either ``alt`` or ``meta``; accept
// both so the binding works across xterm-style and kitty-style
// protocols. Guard against ctrl/super bits so a chord like
// Ctrl+Alt+<key> or Cmd+Alt+<key> doesn't spuriously fire the
// alt binding.
//
// Bare Escape on hermes-ink can arrive as ``key.meta=true`` on some
// terminals, so a configured ``alt+escape`` must not match that shape;
// require an explicit alt bit for escape chords (Copilot round-7
// follow-up on #19835).
return (key.alt === true || (key.meta && key.escape !== true)) && !key.ctrl && key.super !== true
case 'ctrl':
// Require the Ctrl bit AND a clear Alt/Super so a chord like
// Ctrl+Alt+<key> / Ctrl+Cmd+<key> doesn't spuriously match
// ``ctrl+<key>`` (Copilot round-6 review on #19835).
//
// The documented default (``ctrl+b``) additionally accepts the
// explicit ``key.super`` bit on macOS for Cmd+B muscle memory —
// but ONLY ``key.super`` (kitty-style), never ``key.meta``, since
// ``key.meta`` is hermes-ink's Alt signal and accepting it would
// fire the binding on Alt+B.
if (key.ctrl) {
return !key.alt && !key.meta && key.super !== true
}
return _isDefaultVoiceKey(configured) && isMac && key.super === true && !key.alt && !key.meta
case 'super':
// Require the explicit ``key.super`` bit (kitty-style protocol)
// AND clear Ctrl/Alt/Meta so Ctrl+Cmd+X or Alt+Cmd+X don't
// spuriously fire the super binding (Copilot round-6 review on
// #19835). Legacy-terminal users whose Cmd arrives as
// ``key.meta`` need a kitty-protocol terminal — see the
// _MOD_ALIASES doc-comment for the rationale.
return key.super === true && !key.ctrl && !key.alt && !key.meta
}
}
+48
View File
@@ -0,0 +1,48 @@
const PRECISION_WHEEL_FRAME_MS = 16
const PRECISION_WHEEL_STICKY_MS = 80
export type PrecisionWheelState = {
active: boolean
dir: 0 | -1 | 1
lastEventAtMs: number
lastScrollAtMs: number
}
export type PrecisionWheelStep = {
active: boolean
entered: boolean
rows: 0 | 1
}
export function initPrecisionWheel(): PrecisionWheelState {
return { active: false, dir: 0, lastEventAtMs: 0, lastScrollAtMs: 0 }
}
export function computePrecisionWheelStep(
state: PrecisionWheelState,
dir: -1 | 1,
hasModifier: boolean,
now: number
): PrecisionWheelStep {
const active = hasModifier || now - state.lastEventAtMs < PRECISION_WHEEL_STICKY_MS
if (!active) {
state.active = false
return { active: false, entered: false, rows: 0 }
}
const entered = !state.active
state.active = true
state.lastEventAtMs = now
if (dir === state.dir && now - state.lastScrollAtMs < PRECISION_WHEEL_FRAME_MS) {
return { active: true, entered, rows: 0 }
}
state.dir = dir
state.lastScrollAtMs = now
return { active: true, entered, rows: 1 }
}
+36
View File
@@ -0,0 +1,36 @@
const TERMUX_SAFE_PROMPT = '>'
export function composerPromptText(
prompt: string,
profileName?: null | string,
shellMode = false,
termuxMode = false,
totalCols?: number
): string {
if (shellMode) {
return '$'
}
if (termuxMode) {
// Termux fonts/terminal backends can render decorative prompt glyphs with
// ambiguous width; keep the live composer marker strictly single-cell ASCII
// so we never leave stale arrow artifacts while typing.
const basePrompt = TERMUX_SAFE_PROMPT
// On very wide panes we can still include profile context. On narrow/mobile
// panes this burns precious columns and increases wrap/clipping risk.
const wideEnoughForProfile = typeof totalCols === 'number' ? totalCols >= 90 : false
if (wideEnoughForProfile && profileName && !['default', 'custom'].includes(profileName)) {
return `${profileName} ${basePrompt}`
}
return basePrompt
}
if (profileName && !['default', 'custom'].includes(profileName)) {
return `${profileName} ${prompt}`
}
return prompt
}
+55
View File
@@ -0,0 +1,55 @@
const TAGS = ['think', 'reasoning', 'thinking', 'thought', 'REASONING_SCRATCHPAD'] as const
export interface SplitReasoning {
reasoning: string
text: string
}
export function splitReasoning(input: string): SplitReasoning {
let text = input
const reasoning: string[] = []
for (const tag of TAGS) {
const paired = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>\\s*`, 'gi')
text = text.replace(paired, (_m, inner: string) => {
const trimmed = inner.trim()
if (trimmed) {
reasoning.push(trimmed)
}
return ''
})
// Anchor to start-of-input so a literal `<think>` mid-prose (model quoting
// the word, code blocks containing the tag, etc.) doesn't eat every
// paragraph after it. Real unclosed reasoning blocks always lead the
// message — that's how reasoning models stream. See test
// "does not strip trailing prose after a stray mid-text <think> mention".
const unclosed = new RegExp(`^\\s*<${tag}>([\\s\\S]*)$`, 'i')
text = text.replace(unclosed, (_m, inner: string) => {
const trimmed = inner.trim()
if (trimmed) {
reasoning.push(trimmed)
}
return ''
})
}
return {
reasoning: reasoning.join('\n\n').trim(),
text: text.trim()
}
}
export const hasReasoningTag = (input: string) => {
for (const tag of TAGS) {
if (input.includes(`<${tag}>`)) {
return true
}
}
return false
}
+87
View File
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createResizeCoalescer } from './resizeCoalescer.js'
describe('createResizeCoalescer', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('reflows immediately on the first event (leading edge)', () => {
const reflow = vi.fn()
const coalescer = createResizeCoalescer(reflow, 32)
coalescer.schedule()
expect(reflow).toHaveBeenCalledTimes(1)
})
it('collapses a rapid burst into one leading + one trailing reflow', () => {
const reflow = vi.fn()
const coalescer = createResizeCoalescer(reflow, 32)
// A drag: five events inside one 32ms window.
coalescer.schedule()
for (let i = 0; i < 4; i++) {
vi.advanceTimersByTime(5)
coalescer.schedule()
}
// Only the leading edge has fired mid-burst.
expect(reflow).toHaveBeenCalledTimes(1)
// The trailing edge lands the final width once the window settles.
vi.advanceTimersByTime(32)
expect(reflow).toHaveBeenCalledTimes(2)
})
it('reflows immediately again once the interval has elapsed', () => {
const reflow = vi.fn()
const coalescer = createResizeCoalescer(reflow, 32)
coalescer.schedule()
expect(reflow).toHaveBeenCalledTimes(1)
// Gap longer than the window — the next event is a fresh leading edge.
vi.advanceTimersByTime(40)
coalescer.schedule()
expect(reflow).toHaveBeenCalledTimes(2)
})
it('cancel() drops a pending trailing reflow', () => {
const reflow = vi.fn()
const coalescer = createResizeCoalescer(reflow, 32)
coalescer.schedule() // leading
vi.advanceTimersByTime(5)
coalescer.schedule() // schedules a trailing reflow
coalescer.cancel()
vi.advanceTimersByTime(100)
expect(reflow).toHaveBeenCalledTimes(1)
})
it('continuous dragging reflows about once per interval, not per event', () => {
const reflow = vi.fn()
const coalescer = createResizeCoalescer(reflow, 30)
// 30 events over 300ms (one every 10ms) — a sustained drag.
for (let i = 0; i < 30; i++) {
coalescer.schedule()
vi.advanceTimersByTime(10)
}
// Flush the final trailing reflow.
vi.advanceTimersByTime(30)
// ~300ms / 30ms ≈ 10 reflows, not 30. Bound it loosely to stay robust.
expect(reflow.mock.calls.length).toBeLessThanOrEqual(12)
expect(reflow.mock.calls.length).toBeGreaterThanOrEqual(8)
})
})
+56
View File
@@ -0,0 +1,56 @@
export interface ResizeCoalescer {
/** Call on each terminal 'resize' event. */
schedule: () => void
/** Drop any pending trailing reflow (effect cleanup). */
cancel: () => void
}
/**
* Leading + trailing throttle for terminal-resize bursts.
*
* A drag-resize emits a burst of 'resize' events; reflowing on every one
* remounts the visible transcript rows each tick (they're keyed on cols so
* yoga re-measures off live geometry), turning a smooth drag into a
* flickering remount storm.
*
* `schedule()` reflows immediately on the first event (the drag stays
* responsive), then collapses subsequent events to at most one `reflow()`
* per `intervalMs`, and always fires a trailing `reflow()` so the final
* width lands exactly. `cancel()` clears a pending trailing reflow.
*
* Uses `Date.now()` + `setTimeout` directly so it is deterministically
* testable under fake timers.
*/
export function createResizeCoalescer(reflow: () => void, intervalMs: number): ResizeCoalescer {
// -Infinity (not 0) so the first schedule() always satisfies the
// elapsed >= interval leading-edge check regardless of the wall clock.
let lastReflow = Number.NEGATIVE_INFINITY
let trailing: ReturnType<typeof setTimeout> | undefined
const run = () => {
lastReflow = Date.now()
reflow()
}
return {
schedule() {
const elapsed = Date.now() - lastReflow
clearTimeout(trailing)
trailing = undefined
if (elapsed >= intervalMs) {
run()
} else {
trailing = setTimeout(() => {
trailing = undefined
run()
}, intervalMs - elapsed)
}
},
cancel() {
clearTimeout(trailing)
trailing = undefined
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { CommandDispatchResponse } from '../gatewayTypes.js'
export type RpcResult = Record<string, any>
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
!value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
const o = asRpcResult(value)
if (!o || typeof o.type !== 'string') {
return null
}
const t = o.type
if (t === 'exec' || t === 'plugin') {
return { type: t, output: typeof o.output === 'string' ? o.output : undefined }
}
if (t === 'alias' && typeof o.target === 'string') {
return { type: 'alias', target: o.target }
}
const str = (value: unknown) => (typeof value === 'string' ? value : undefined)
if (t === 'skill' && typeof o.name === 'string') {
return { type: 'skill', name: o.name, message: str(o.message), display: str(o.display) }
}
if (t === 'send' && typeof o.message === 'string') {
return {
type: 'send',
message: o.message,
notice: str(o.notice),
display: str(o.display)
}
}
if (t === 'prefill' && typeof o.message === 'string') {
return {
type: 'prefill',
message: o.message,
notice: typeof o.notice === 'string' ? o.notice : undefined
}
}
return null
}
export const rpcErrorMessage = (err: unknown) =>
err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed'
+147
View File
@@ -0,0 +1,147 @@
// Star Map palette — ported from apps/desktop/src/app/starmap/color.ts so the
// TUI overlay derives the same memory ink (complement of the theme primary) and
// the same age fade (rgba(ink, alpha) over the background) as the desktop panel.
interface Rgb {
b: number
g: number
r: number
}
function hexToRgb(hex: string): Rgb {
let s = hex.trim().replace(/^#/, '')
if (s.length === 3) {
s = s
.split('')
.map(c => c + c)
.join('')
}
const n = parseInt(s, 16)
if (Number.isNaN(n) || s.length < 6) {
return { b: 0, g: 215, r: 255 }
}
return { b: n & 255, g: (n >> 8) & 255, r: (n >> 16) & 255 }
}
function rgbToHex(c: Rgb): string {
const h = (v: number) =>
Math.max(0, Math.min(255, Math.round(v)))
.toString(16)
.padStart(2, '0')
return `#${h(c.r)}${h(c.g)}${h(c.b)}`
}
function mix(a: Rgb, b: Rgb, t: number): Rgb {
const p = Math.max(0, Math.min(1, t))
return { b: a.b + (b.b - a.b) * p, g: a.g + (b.g - a.g) * p, r: a.r + (b.r - a.r) * p }
}
function luminance(c: Rgb): number {
return (0.2126 * c.r + 0.7152 * c.g + 0.114 * c.b) / 255
}
function rgbToHsl(c: Rgb): [number, number, number] {
const r = c.r / 255
const g = c.g / 255
const b = c.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
if (!d) {
return [0, 0, l]
}
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
return [h, s, l]
}
function hslToRgb(h: number, s: number, l: number): Rgb {
const hue = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x]
return { b: (b + m) * 255, g: (g + m) * 255, r: (r + m) * 255 }
}
function complementaryInk(c: Rgb): Rgb {
const [h, s, l] = rgbToHsl(c)
return hslToRgb(h + 165, Math.max(s, 0.5), Math.max(0.5, Math.min(0.7, l)))
}
export interface StarmapPalette {
bg: Rgb
dim: Rgb
label: Rgb
memory: Rgb
skill: Rgb
}
/** Derive the Star Map inks from the theme primary + foreground color. */
export function deriveStarmapPalette(primaryHex: string, fgHex: string): StarmapPalette {
const primary = hexToRgb(primaryHex)
const dark = luminance(hexToRgb(fgHex)) > 0.55
const base: Rgb = dark ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 }
const bg: Rgb = dark ? { b: 12, g: 8, r: 8 } : { b: 250, g: 250, r: 250 }
return {
bg,
dim: mix(base, bg, 0.7),
label: mix(base, bg, 0.35),
// Memories are drillable, so they wear the primary "clickable" ink; skills
// are dead-ends and get the muted complement.
memory: mix(primary, base, dark ? 0.12 : 0.18),
skill: mix(complementaryInk(primary), bg, 0.45)
}
}
/** Fade an explicit hex ink toward the background by alpha (for category bars). */
export function fadeHex(palette: StarmapPalette, hex: string, alpha: number): string {
const base = hexToRgb(hex)
return rgbToHex(alpha >= 0.999 ? base : mix(palette.bg, base, alpha))
}
/** Fade a base ink toward the background by alpha (rgba-over-bg), as a hex. */
export function fadeInk(palette: StarmapPalette, style: string, alpha: number): string | undefined {
const base =
style === 'skill'
? palette.skill
: style === 'memory'
? palette.memory
: style === 'label'
? palette.label
: style === 'dim'
? palette.dim
: null
if (!base) {
return undefined
}
return rgbToHex(alpha >= 0.999 ? base : mix(palette.bg, base, alpha))
}
+351
View File
@@ -0,0 +1,351 @@
import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js'
const ROOT_KEY = '__root__'
/**
* Reconstruct the subagent spawn tree from a flat event-ordered list.
*
* Grouping is by `parentId`; a missing `parentId` (or one pointing at an
* unknown subagent) is treated as a top-level spawn of the current turn.
* Children within a parent are sorted by `depth` then `index` — same key
* used in `turnController.upsertSubagent`, so render order matches spawn
* order regardless of network reordering of gateway events.
*
* Older gateways omit `parentId`; every subagent is then a top-level node
* and the tree renders flat — matching pre-observability behaviour.
*/
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
if (!items.length) {
return []
}
const byParent = new Map<string, SubagentProgress[]>()
const known = new Set<string>()
for (const item of items) {
known.add(item.id)
}
for (const item of items) {
const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY
const bucket = byParent.get(parentKey) ?? []
bucket.push(item)
byParent.set(parentKey, bucket)
}
for (const bucket of byParent.values()) {
bucket.sort((a, b) => a.depth - b.depth || a.index - b.index)
}
const build = (item: SubagentProgress): SubagentNode => {
const kids = byParent.get(item.id) ?? []
const children = kids.map(build)
return { aggregate: aggregate(item, children), children, item }
}
return (byParent.get(ROOT_KEY) ?? []).map(build)
}
/**
* Roll up counts for a node's whole subtree. Kept pure so the live view
* and the post-hoc replay can share the same renderer unchanged.
*
* `hotness` = tools per second across the subtree — a crude proxy for
* "how much work is happening in this branch". Used to colour tree rails
* in the overlay / inline view so the eye spots the expensive branch.
*/
export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate {
let totalTools = item.toolCount ?? 0
let totalDuration = item.durationSeconds ?? 0
let descendantCount = 0
let activeCount = isRunning(item) ? 1 : 0
let maxDepthFromHere = 0
let inputTokens = item.inputTokens ?? 0
let outputTokens = item.outputTokens ?? 0
let costUsd = item.costUsd ?? 0
let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0)
for (const child of children) {
totalTools += child.aggregate.totalTools
totalDuration += child.aggregate.totalDuration
descendantCount += child.aggregate.descendantCount + 1
activeCount += child.aggregate.activeCount
maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1)
inputTokens += child.aggregate.inputTokens
outputTokens += child.aggregate.outputTokens
costUsd += child.aggregate.costUsd
filesTouched += child.aggregate.filesTouched
}
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
return {
activeCount,
costUsd,
descendantCount,
filesTouched,
hotness,
inputTokens,
maxDepthFromHere,
outputTokens,
totalDuration,
totalTools
}
}
/**
* Count of subagents at each depth level, indexed by depth (0 = top level).
* Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD.
*/
export function widthByDepth(tree: readonly SubagentNode[]): number[] {
const widths: number[] = []
const walk = (nodes: readonly SubagentNode[], depth: number) => {
if (!nodes.length) {
return
}
widths[depth] = (widths[depth] ?? 0) + nodes.length
for (const node of nodes) {
walk(node.children, depth + 1)
}
}
walk(tree, 0)
return widths
}
/**
* Flat totals across the full tree — feeds the summary chip header.
*/
export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate {
let totalTools = 0
let totalDuration = 0
let descendantCount = 0
let activeCount = 0
let maxDepthFromHere = 0
let inputTokens = 0
let outputTokens = 0
let costUsd = 0
let filesTouched = 0
for (const node of tree) {
totalTools += node.aggregate.totalTools
totalDuration += node.aggregate.totalDuration
descendantCount += node.aggregate.descendantCount + 1
activeCount += node.aggregate.activeCount
maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1)
inputTokens += node.aggregate.inputTokens
outputTokens += node.aggregate.outputTokens
costUsd += node.aggregate.costUsd
filesTouched += node.aggregate.filesTouched
}
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
return {
activeCount,
costUsd,
descendantCount,
filesTouched,
hotness,
inputTokens,
maxDepthFromHere,
outputTokens,
totalDuration,
totalTools
}
}
/**
* Flatten the tree into visit order — useful for keyboard navigation and
* for "kill subtree" walks that fire one RPC per descendant.
*/
export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] {
const out: SubagentNode[] = []
const walk = (nodes: readonly SubagentNode[]) => {
for (const node of nodes) {
out.push(node)
walk(node.children)
}
}
walk(tree)
return out
}
/**
* Collect every descendant's id for a given node (excluding the node itself).
*/
export function descendantIds(node: SubagentNode): string[] {
const ids: string[] = []
const walk = (children: readonly SubagentNode[]) => {
for (const child of children) {
ids.push(child.item.id)
walk(child.children)
}
}
walk(node.children)
return ids
}
export function isRunning(item: Pick<SubagentProgress, 'status'>): boolean {
return item.status === 'running' || item.status === 'queued'
}
const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
/**
* 8-step unicode bar sparkline from a positive-integer array. Zeroes render
* as spaces so a sparse tree doesn't read as equal activity at every depth.
*/
export function sparkline(values: readonly number[]): string {
if (!values.length) {
return ''
}
const max = Math.max(...values)
if (max <= 0) {
return ' '.repeat(values.length)
}
return values
.map(v => {
if (v <= 0) {
return ' '
}
const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1))))
return SPARK_RAMP[idx]
})
.join('')
}
/**
* Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s`
*/
export function formatSummary(totals: SubagentAggregate): string {
const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`]
pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`)
if (totals.totalTools > 0) {
pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`)
}
if (totals.totalDuration > 0) {
pieces.push(fmtDuration(totals.totalDuration))
}
const tokens = totals.inputTokens + totals.outputTokens
if (tokens > 0) {
pieces.push(`${fmtTokens(tokens)} tok`)
}
if (totals.activeCount > 0) {
pieces.push(`⚡${totals.activeCount}`)
}
return pieces.join(' · ')
}
/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */
export function fmtCost(usd: number): string {
if (!Number.isFinite(usd) || usd <= 0) {
return ''
}
if (usd < 0.01) {
return '<$0.01'
}
if (usd < 10) {
return `$${usd.toFixed(2)}`
}
return `$${usd.toFixed(1)}`
}
/** Compact token count: `12k`, `1.2k`, `542`. */
export function fmtTokens(n: number): string {
if (!Number.isFinite(n) || n <= 0) {
return '0'
}
if (n < 1000) {
return String(Math.round(n))
}
if (n < 10_000) {
return `${(n / 1000).toFixed(1)}k`
}
return `${Math.round(n / 1000)}k`
}
/**
* `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents
* overlay so the timeline + list + summary all speak the same dialect.
*/
export function fmtDuration(seconds: number): string {
if (seconds < 60) {
return `${Math.max(0, Math.round(seconds))}s`
}
const m = Math.floor(seconds / 60)
const s = Math.round(seconds - m * 60)
return s === 0 ? `${m}m` : `${m}m ${s}s`
}
/**
* A subagent is top-level if it has no `parentId`, or its parent isn't in
* the same snapshot (orphaned by a pruned mid-flight root). Same rule
* `buildSubagentTree` uses — keep call sites consistent across the live
* view, disk label, and diff pane.
*/
export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] {
const ids = new Set(items.map(s => s.id))
return items.filter(s => !s.parentId || !ids.has(s.parentId))
}
/**
* Normalize a node's hotness into a palette index 0..N-1 where N = buckets.
* Higher hotness = "hotter" colour. Normalized against the tree's peak hotness
* so a uniformly slow tree still shows gradient across its busiest branches.
*/
export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number {
if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) {
return 0
}
const ratio = Math.min(1, hotness / peakHotness)
return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1))))
}
export function peakHotness(tree: readonly SubagentNode[]): number {
let peak = 0
const walk = (nodes: readonly SubagentNode[]) => {
for (const node of nodes) {
peak = Math.max(peak, node.aggregate.hotness)
walk(node.children)
}
}
walk(tree)
return peak
}
+117
View File
@@ -0,0 +1,117 @@
import type { Theme } from '../theme.js'
export type Token = [string, string]
interface LangSpec {
comment: null | string
keywords: Set<string>
}
const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
const TS = KW(`
abstract as async await break case catch class const continue debugger default delete do else enum export extends
false finally for from function get if implements import in instanceof interface is let new null of package private
protected public readonly return set static super switch this throw true try type typeof undefined var void while
with yield
`)
const PY = KW(`
False None True and as assert async await break class continue def del elif else except finally for from global if
import in is lambda nonlocal not or pass raise return try while with yield
`)
const SH = KW(`
if then else elif fi for in do done while until case esac function return break continue local export readonly
declare typeset
`)
const GO = KW(`
break case chan const continue default defer else fallthrough for func go goto if import interface map package range
return select struct switch type var nil true false
`)
const RUST = KW(`
as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut
pub ref return self Self static struct super trait true type unsafe use where while yield
`)
const SQL = KW(`
select from where and or not in is null as by group order limit offset insert into values update set delete create
table drop alter add column primary key foreign references join left right inner outer on
`)
const LANGS: Record<string, LangSpec> = {
go: { comment: '//', keywords: GO },
json: { comment: null, keywords: KW('true false null') },
py: { comment: '#', keywords: PY },
rust: { comment: '//', keywords: RUST },
sh: { comment: '#', keywords: SH },
sql: { comment: '--', keywords: SQL },
ts: { comment: '//', keywords: TS },
yaml: { comment: '#', keywords: KW('true false null yes no on off') }
}
const ALIAS: Record<string, string> = {
bash: 'sh',
javascript: 'ts',
js: 'ts',
jsx: 'ts',
python: 'py',
rs: 'rust',
shell: 'sh',
tsx: 'ts',
typescript: 'ts',
yml: 'yaml',
zsh: 'sh'
}
const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null
export const isHighlightable = (lang: string): boolean => resolve(lang) !== null
const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g
export function highlightLine(line: string, lang: string, t: Theme): Token[] {
const spec = resolve(lang)
if (!spec) {
return [['', line]]
}
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
return [[t.color.syntaxComment, line]]
}
const tokens: Token[] = []
let last = 0
for (const m of line.matchAll(TOKEN_RE)) {
const start = m.index ?? 0
if (start > last) {
tokens.push(['', line.slice(last, start)])
}
const tok = m[0]
const ch = tok[0]!
if (ch === '"' || ch === "'" || ch === '`') {
tokens.push([t.color.syntaxString, tok])
} else if (ch >= '0' && ch <= '9') {
tokens.push([t.color.syntaxNumber, tok])
} else if (spec.keywords.has(tok)) {
tokens.push([t.color.syntaxKeyword, tok])
} else {
tokens.push(['', tok])
}
last = start + tok.length
}
if (last < line.length) {
tokens.push(['', line.slice(last)])
}
return tokens
}
+103
View File
@@ -0,0 +1,103 @@
import { writeSync } from 'node:fs'
export const TERMINAL_MODE_RESET =
"\x1b[0'z" + // DEC locator reporting
"\x1b[0'{" + // selectable locator events
'\x1b[?2029l' + // passive mouse
'\x1b[?1016l' + // SGR-pixels mouse
'\x1b[?1015l' + // urxvt decimal mouse
'\x1b[?1006l' + // SGR mouse
'\x1b[?1005l' + // UTF-8 extended mouse
'\x1b[?1003l' + // any-motion mouse
'\x1b[?1002l' + // button-motion mouse
'\x1b[?1001l' + // highlight mouse
'\x1b[?1000l' + // click mouse
'\x1b[?9l' + // X10 mouse
'\x1b[?1004l' + // focus events
'\x1b[?2004l' + // bracketed paste
'\x1b[?1049l' + // alternate screen
'\x1b[<u' + // kitty keyboard
'\x1b[>4m' + // modifyOtherKeys
'\x1b[0m' + // attributes
'\x1b[?25h' // cursor visible
type ResettableStream = Pick<NodeJS.WriteStream, 'isTTY' | 'write'> & {
fd?: number
}
// OSC 10/11 set the terminal's DEFAULT foreground/background — so every cell,
// including text rendered with no explicit color (markdown body, borders,
// third-party output), takes the skin instead of the host profile's defaults.
// OSC 110/111 restore the terminal's own values. We only reset what we
// actually painted, so a skinless session leaves the terminal untouched.
const HEX_RE = /^#[0-9a-f]{6}$/i
/** True when `hex` is a paintable default (the same bar `set` applies). */
export const isPaintableHex = (hex: string): boolean => HEX_RE.test(hex)
/**
* A paintable terminal default (fg=10, bg=11). `set(hex)` paints from a skin,
* or clears back to the terminal's own default when `hex` is empty/invalid
* (a skin without the key, e.g. reverting to `default`). Runtime writes go
* through the async stream so they order cleanly with Ink's frames; the
* exit-time restore rides `resetTerminalModes` (writeSync). No-op off a TTY.
*/
const defaultColorSlot = (osc: 10 | 11) => {
const restore = `\x1b]1${osc}\x07`
let painted = false
const set = (hex: string, stream: ResettableStream = process.stdout): void => {
if (!stream.isTTY) {
return
}
try {
if (HEX_RE.test(hex)) {
stream.write(`\x1b]${osc};${hex}\x07`)
painted = true
} else if (painted) {
stream.write(restore)
painted = false
}
} catch {
// Terminal that can't take it just keeps its default.
}
}
return { restoreSeq: () => (painted ? restore : ''), set }
}
const foreground = defaultColorSlot(10)
const background = defaultColorSlot(11)
export const setTerminalForeground = foreground.set
export const setTerminalBackground = background.set
export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean {
if (!stream.isTTY) {
return false
}
// Append default-color restores only for what we painted, so a normal
// session never resets a terminal it didn't touch.
const reset = TERMINAL_MODE_RESET + foreground.restoreSeq() + background.restoreSeq()
const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined
if (fd !== undefined) {
try {
writeSync(fd, reset)
return true
} catch {
// Fall through to stream.write for mocked or unusual TTY streams.
}
}
try {
stream.write(reset)
return true
} catch {
return false
}
}
+78
View File
@@ -0,0 +1,78 @@
import {
detectVSCodeLikeTerminal,
type FileOps,
isRemoteShellSession,
shouldPromptForTerminalSetup
} from './terminalSetup.js'
export type MacTerminalHint = {
key: string
message: string
tone: 'info' | 'warn'
}
export type MacTerminalContext = {
isAppleTerminal: boolean
isRemote: boolean
isTmux: boolean
vscodeLike: null | 'cursor' | 'vscode' | 'windsurf'
}
export function detectMacTerminalContext(env: NodeJS.ProcessEnv = process.env): MacTerminalContext {
const termProgram = env['TERM_PROGRAM'] ?? ''
return {
isAppleTerminal: termProgram === 'Apple_Terminal' || !!env['TERM_SESSION_ID'],
isRemote: isRemoteShellSession(env),
isTmux: !!env['TMUX'],
vscodeLike: detectVSCodeLikeTerminal(env)
}
}
export async function terminalParityHints(
env: NodeJS.ProcessEnv = process.env,
options?: { fileOps?: Partial<FileOps>; homeDir?: string }
): Promise<MacTerminalHint[]> {
const ctx = detectMacTerminalContext(env)
const hints: MacTerminalHint[] = []
if (
ctx.vscodeLike &&
(await shouldPromptForTerminalSetup({ env, fileOps: options?.fileOps, homeDir: options?.homeDir }))
) {
hints.push({
key: 'ide-setup',
tone: 'info',
message: `Detected ${ctx.vscodeLike} terminal · run /terminal-setup for best Cmd+Enter / undo parity`
})
}
if (ctx.isAppleTerminal) {
hints.push({
key: 'apple-terminal',
tone: 'warn',
message:
'Apple Terminal detected · use /paste for image-only clipboard fallback, and try Ctrl+A / Ctrl+E / Ctrl+U if Cmd+←/→/⌫ gets rewritten'
})
}
if (ctx.isTmux) {
hints.push({
key: 'tmux',
tone: 'warn',
message:
'tmux detected · clipboard copy/paste uses passthrough when available; allow-passthrough improves OSC52 reliability'
})
}
if (ctx.isRemote) {
hints.push({
key: 'remote',
tone: 'warn',
message:
'SSH session detected · text clipboard can bridge via OSC52, but image clipboard and local screenshot paths still depend on the machine running Hermes'
})
}
return hints
}
+505
View File
@@ -0,0 +1,505 @@
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
export type SupportedTerminal = 'cursor' | 'vscode' | 'windsurf'
export type FileOps = {
copyFile: typeof copyFile
mkdir: typeof mkdir
readFile: typeof readFile
writeFile: typeof writeFile
}
type Keybinding = {
args?: { text?: string }
command?: string
key?: string
when?: string
}
export type TerminalSetupResult = {
message: string
requiresRestart?: boolean
success: boolean
}
const DEFAULT_FILE_OPS: FileOps = { copyFile, mkdir, readFile, writeFile }
const COPY_SEQUENCE = '\u001b[99;13u'
// Kitty keyboard protocol CSI u sequences for modified Enter keys.
// Codepoint 13 = Enter; modifier encoding: 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) + (super?8:0).
// These are recognized by Ink's parse-keypress CSI u handler and produce
// key.return=true with the correct modifier flags, so textInput.tsx's
// existing k.return && (k.shift || k.ctrl || ...) branch inserts a newline.
const SHIFT_ENTER_SEQUENCE = '\u001b[13;2u' // modifier 2 = shift
const CTRL_ENTER_SEQUENCE = '\u001b[13;5u' // modifier 5 = ctrl
const SUPER_ENTER_SEQUENCE = '\u001b[13;9u' // modifier 9 = super (Cmd on macOS)
// Legacy multiline sequence used before CSI u migration. Old keybindings
// that still send this will be auto-replaced on next terminal setup.
const LEGACY_MULTILINE_SEQUENCE = '\\\r\n'
/**
* Migrate legacy keybindings that used the old \\\r\n escape sequence
* for modified Enter keys. Those sequences arrived at Ink as separate
* key events (backslash + return), causing unintended submissions.
* The replacement CSI u sequences are parsed correctly by Ink's
* parse-keypress handler and produce the proper modifier flags.
*/
function migrateLegacyBindings(keybindings: unknown[]): number {
let migrated = 0
const replacements: Map<string, string> = new Map([
['shift+enter', SHIFT_ENTER_SEQUENCE],
['ctrl+enter', CTRL_ENTER_SEQUENCE],
['cmd+enter', SUPER_ENTER_SEQUENCE]
])
for (let i = 0; i < keybindings.length; i++) {
const entry = keybindings[i]
if (!isKeybinding(entry)) {
continue
}
const replacement = replacements.get(entry.key ?? '')
if (
replacement &&
entry.command === 'workbench.action.terminal.sendSequence' &&
entry.when === 'terminalFocus' &&
entry.args?.text === LEGACY_MULTILINE_SEQUENCE
) {
keybindings[i] = { ...entry, args: { text: replacement } }
migrated += 1
}
}
return migrated
}
const TERMINAL_META: Record<SupportedTerminal, { appName: string; label: string }> = {
vscode: { appName: 'Code', label: 'VS Code' },
cursor: { appName: 'Cursor', label: 'Cursor' },
windsurf: { appName: 'Windsurf', label: 'Windsurf' }
}
const MAC_COPY_BINDING: Keybinding = {
key: 'cmd+c',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus && terminalTextSelected',
args: { text: COPY_SEQUENCE }
}
const BASE_BINDINGS: Keybinding[] = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: SHIFT_ENTER_SEQUENCE }
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: CTRL_ENTER_SEQUENCE }
},
{
key: 'cmd+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: SUPER_ENTER_SEQUENCE }
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;9u' }
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;10u' }
}
]
const targetBindings = (platform: NodeJS.Platform): Keybinding[] =>
platform === 'darwin' ? [MAC_COPY_BINDING, ...BASE_BINDINGS] : BASE_BINDINGS
export function detectVSCodeLikeTerminal(env: NodeJS.ProcessEnv = process.env): null | SupportedTerminal {
const askpass = env['VSCODE_GIT_ASKPASS_MAIN']?.toLowerCase() ?? ''
if (env['CURSOR_TRACE_ID'] || askpass.includes('cursor')) {
return 'cursor'
}
if (askpass.includes('windsurf')) {
return 'windsurf'
}
if (env['TERM_PROGRAM'] === 'vscode' || env['VSCODE_GIT_IPC_HANDLE']) {
return 'vscode'
}
return null
}
/**
* Strip JSONC features (// line comments, /* block comments *\/, trailing commas)
* so the result is valid JSON parseable by JSON.parse().
* Handles comments inside strings correctly (preserves them).
*/
export function stripJsonComments(content: string): string {
let result = ''
let i = 0
const len = content.length
while (i < len) {
const ch = content[i]!
// String literal — copy as-is, including any comment-like chars inside
if (ch === '"') {
let j = i + 1
while (j < len) {
if (content[j] === '\\') {
j += 2 // skip escaped char
} else if (content[j] === '"') {
j++
break
} else {
j++
}
}
result += content.slice(i, j)
i = j
continue
}
// Line comment
if (ch === '/' && content[i + 1] === '/') {
const eol = content.indexOf('\n', i)
i = eol === -1 ? len : eol
continue
}
// Block comment
if (ch === '/' && content[i + 1] === '*') {
const end = content.indexOf('*/', i + 2)
i = end === -1 ? len : end + 2
continue
}
result += ch
i++
}
// Remove trailing commas before ] or }
return result.replace(/,(\s*[}\]])/g, '$1')
}
export function isRemoteShellSession(env: NodeJS.ProcessEnv): boolean {
return Boolean(env['SSH_CONNECTION'] || env['SSH_TTY'] || env['SSH_CLIENT'])
}
export function getVSCodeStyleConfigDir(
appName: string,
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir: string = homedir()
): null | string {
if (platform === 'darwin') {
return join(homeDir, 'Library', 'Application Support', appName, 'User')
}
if (platform === 'win32') {
return env['APPDATA'] ? join(env['APPDATA'], appName, 'User') : null
}
return join(homeDir, '.config', appName, 'User')
}
function isKeybinding(value: unknown): value is Keybinding {
return typeof value === 'object' && value !== null
}
function sameBinding(a: Keybinding, b: Keybinding): boolean {
return a.key === b.key && a.command === b.command && a.when === b.when && a.args?.text === b.args?.text
}
type WhenRequirements = {
forbidden: Set<string>
required: Set<string>
}
const WHEN_TOKEN_RE = /!?[A-Za-z_][\w.]*/g
function parseWhenRequirements(when: string): WhenRequirements {
const required = new Set<string>()
const forbidden = new Set<string>()
for (const [token] of when.matchAll(WHEN_TOKEN_RE)) {
if (token.startsWith('!')) {
forbidden.add(token.slice(1))
} else {
required.add(token)
}
}
return { forbidden, required }
}
function requirementsContradict(a: WhenRequirements, b: WhenRequirements): boolean {
for (const token of a.required) {
if (b.forbidden.has(token)) {
return true
}
}
for (const token of b.required) {
if (a.forbidden.has(token)) {
return true
}
}
return false
}
function whensOverlap(a: string, b: string): boolean {
if (a === b) {
return true
}
// Empty when = global, overlaps every context.
if (!a || !b) {
return true
}
const left = parseWhenRequirements(a)
const right = parseWhenRequirements(b)
if (requirementsContradict(left, right)) {
return false
}
// This intentionally avoids a full VS Code when-clause parser. If two
// same-key bindings share a positive context token and don't explicitly
// contradict each other, they can fire together in that context.
for (const token of left.required) {
if (right.required.has(token)) {
return true
}
}
return false
}
// VS Code allows multiple bindings on the same key as long as their `when`
// clauses don't overlap. We flag a conflict when the contexts overlap but
// the bindings differ — e.g. existing `terminalFocus` cmd+c overlaps with
// our `terminalFocus && terminalTextSelected`, so the existing binding
// would shadow ours when text isn't selected.
function bindingsConflict(existing: Keybinding, target: Keybinding): boolean {
if (existing.key !== target.key) {
return false
}
if (!whensOverlap(existing.when ?? '', target.when ?? '')) {
return false
}
return !sameBinding(existing, target)
}
async function backupFile(filePath: string, ops: FileOps): Promise<void> {
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
await ops.copyFile(filePath, `${filePath}.backup.${stamp}`)
}
export async function configureTerminalKeybindings(
terminal: SupportedTerminal,
options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}
): Promise<TerminalSetupResult> {
const env = options?.env ?? process.env
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[terminal]
if (isRemoteShellSession(env)) {
return {
success: false,
message: `${meta.label} terminal setup must be run on the local machine, not inside an SSH session.`
}
}
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return {
success: false,
message: `Could not determine ${meta.label} settings path on this platform.`
}
}
const keybindingsFile = join(configDir, 'keybindings.json')
try {
await ops.mkdir(configDir, { recursive: true })
let keybindings: unknown[] = []
let hasExistingFile = false
try {
const content = await ops.readFile(keybindingsFile, 'utf8')
hasExistingFile = true
const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) {
return {
success: false,
message: `${meta.label} keybindings.json is not a JSON array: ${keybindingsFile}`
}
}
keybindings = parsed
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') {
return {
success: false,
message: `Failed to read ${meta.label} keybindings: ${error}`
}
}
}
const migrated = migrateLegacyBindings(keybindings)
const targets = targetBindings(platform)
const conflicts = targets.filter(target =>
keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target))
)
if (conflicts.length) {
return {
success: false,
message:
`Existing terminal keybindings would conflict in ${keybindingsFile}: ` + conflicts.map(c => c.key).join(', ')
}
}
let added = 0
for (const target of targets.slice().reverse()) {
const exists = keybindings.some(existing => isKeybinding(existing) && sameBinding(existing, target))
if (!exists) {
keybindings.unshift(target)
added += 1
}
}
if (!added && !migrated) {
return {
success: true,
message: `${meta.label} terminal keybindings already configured.`
}
}
if (hasExistingFile && (added || migrated)) {
await backupFile(keybindingsFile, ops)
}
await ops.writeFile(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8')
const parts: string[] = []
if (added) {
parts.push(`Added ${added} ${meta.label} terminal keybinding${added === 1 ? '' : 's'}`)
}
if (migrated) {
parts.push(`migrated ${migrated} legacy binding${migrated === 1 ? '' : 's'} to CSI u encoding`)
}
return {
success: true,
requiresRestart: true,
message: `${parts.join(', ')} in ${keybindingsFile}`
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${meta.label} terminal shortcuts: ${error}`
}
}
}
export async function configureDetectedTerminalKeybindings(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<TerminalSetupResult> {
const detected = detectVSCodeLikeTerminal(options?.env ?? process.env)
if (!detected) {
return {
success: false,
message: 'No supported IDE terminal detected. Supported: VS Code, Cursor, Windsurf.'
}
}
return configureTerminalKeybindings(detected, options)
}
export async function shouldPromptForTerminalSetup(options?: {
env?: NodeJS.ProcessEnv
fileOps?: Partial<FileOps>
homeDir?: string
platform?: NodeJS.Platform
}): Promise<boolean> {
const env = options?.env ?? process.env
const detected = detectVSCodeLikeTerminal(env)
if (!detected || isRemoteShellSession(env)) {
return false
}
const platform = options?.platform ?? process.platform
const homeDir = options?.homeDir ?? homedir()
const ops: FileOps = { ...DEFAULT_FILE_OPS, ...(options?.fileOps ?? {}) }
const meta = TERMINAL_META[detected]
const configDir = getVSCodeStyleConfigDir(meta.appName, platform, env, homeDir)
if (!configDir) {
return false
}
try {
const content = await ops.readFile(join(configDir, 'keybindings.json'), 'utf8')
const parsed: unknown = JSON.parse(stripJsonComments(content))
if (!Array.isArray(parsed)) {
return true
}
return targetBindings(platform).some(
target => !parsed.some(existing => isKeybinding(existing) && sameBinding(existing, target))
)
} catch {
return true
}
}
+31
View File
@@ -0,0 +1,31 @@
const TERMUX_PREFIX = '/data/data/com.termux/files/usr'
const truthy = (value?: string) => /^(?:1|true|yes|on)$/i.test(String(value ?? '').trim())
export const isTermuxEnv = (env: NodeJS.ProcessEnv = process.env): boolean => {
const prefix = String(env.PREFIX ?? '')
return Boolean(env.TERMUX_VERSION) || prefix.includes(TERMUX_PREFIX)
}
/**
* Return true when Hermes should enable Termux-focused TUI defaults.
*
* Defaults to on in Termux, with an explicit opt-out for debugging:
* HERMES_TUI_TERMUX_MODE=0
*/
export const isTermuxTuiMode = (env: NodeJS.ProcessEnv = process.env): boolean => {
if (!isTermuxEnv(env)) {
return false
}
const override = String(env.HERMES_TUI_TERMUX_MODE ?? '')
.trim()
.toLowerCase()
if (override) {
return truthy(override)
}
return true
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
clarifyBatchRevisitState,
formatAbandonedClarify,
formatAbandonedClarifyBatch,
stripTrailingPasteNewlines
} from './text.js'
describe('stripTrailingPasteNewlines', () => {
it('removes trailing newline runs from pasted text', () => {
expect(stripTrailingPasteNewlines('alpha\n')).toBe('alpha')
expect(stripTrailingPasteNewlines('alpha\nbeta\n\n')).toBe('alpha\nbeta')
})
it('preserves interior newlines', () => {
expect(stripTrailingPasteNewlines('alpha\nbeta\ngamma')).toBe('alpha\nbeta\ngamma')
})
it('preserves newline-only pastes', () => {
expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n')
})
})
describe('formatAbandonedClarify', () => {
it('renders the question, numbered options, and reason', () => {
const out = formatAbandonedClarify('How do you want to scope?', ['Option A', 'Option B', 'Option C'], 'timed out')
expect(out).toBe(
[
'ask How do you want to scope?',
' 1. Option A',
' 2. Option B',
' 3. Option C',
' (timed out — no selection)'
].join('\n')
)
})
it('handles a prompt with no choices (free-text clarify)', () => {
const out = formatAbandonedClarify('What is the target branch?', null, 'cancelled')
expect(out).toBe(['ask What is the target branch?', ' (cancelled — no selection)'].join('\n'))
})
it('trims surrounding whitespace on the question', () => {
const out = formatAbandonedClarify(' trailing space ', [], 'timed out')
expect(out.split('\n')[0]).toBe('ask trailing space')
})
it('numbers options 1-based to match the live ClarifyPrompt', () => {
const out = formatAbandonedClarify('q', ['first'], 'timed out')
expect(out).toContain(' 1. first')
expect(out).not.toContain(' 0.')
})
})
describe('formatAbandonedClarifyBatch', () => {
it('shows locked answers and marks unanswered questions', () => {
const out = formatAbandonedClarifyBatch(
[
{ qid: 'q0', question: 'One?' },
{ qid: 'q1', question: 'Two?' }
],
{ q0: 'alpha' },
'timed out'
)
expect(out).toBe(['ask (2 questions)', ' ✓ One? → alpha', ' · Two? (no answer)', ' (timed out)'].join('\n'))
})
it('treats an empty locked answer as unanswered in the record', () => {
const out = formatAbandonedClarifyBatch([{ qid: 'q0', question: 'One?' }], { q0: '' }, 'cancelled')
expect(out).toContain('· One? (no answer)')
})
})
describe('clarifyBatchRevisitState', () => {
it('restores the cursor onto a choice answer', () => {
expect(clarifyBatchRevisitState(['red', 'blue'], 'blue')).toEqual({ custom: '', sel: 1 })
})
it('stages a typed answer on the Other row for editing', () => {
expect(clarifyBatchRevisitState(['red', 'blue'], 'chartreuse')).toEqual({ custom: 'chartreuse', sel: 2 })
})
it('stages a typed answer for an open-ended question (no choices)', () => {
expect(clarifyBatchRevisitState([], 'free text')).toEqual({ custom: 'free text', sel: 0 })
})
it('resets cleanly for unanswered and empty answers', () => {
expect(clarifyBatchRevisitState(['red'], undefined)).toEqual({ custom: '', sel: 0 })
expect(clarifyBatchRevisitState(['red'], '')).toEqual({ custom: '', sel: 0 })
})
})
+420
View File
@@ -0,0 +1,420 @@
import {
LIVE_RENDER_MAX_CHARS,
LIVE_RENDER_MAX_LINES,
THINKING_COT_MAX,
VERBOSE_TRAIL_MAX_CHARS,
VERBOSE_TRAIL_MAX_LINES
} from '../config/limits.js'
import { VERBS } from '../content/verbs.js'
import type { ThinkingMode } from '../types.js'
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
const ANSI_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
const ANSI_CSI_WITH_CMD_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*([@-~])`, 'g')
const ANSI_INCOMPLETE_CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*(?=${ESC}|\\n|$)`, 'g')
const ANSI_OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_STRING_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
const ANSI_NON_CSI_ESC_SEQ_RE = new RegExp(`${ESC}(?!\\[|\\]|P|X|\\^|_)[ -/]*[0-~]`, 'g')
const ANSI_STRAY_ESC_RE = new RegExp(`${ESC}(?!\\[)[\\s\\S]?`, 'g')
// eslint-disable-next-line no-control-regex -- intentionally strips C0/C1 control chars
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0D\x0E-\x1A\x1C-\x1F\x7F]/g
const WS_RE = /\s+/g
export const stripAnsi = (s: string) =>
s
.replace(ANSI_OSC_RE, '')
.replace(ANSI_STRING_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_CSI_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
.replace(ANSI_STRAY_ESC_RE, '')
.replace(CONTROL_RE, '')
export const sanitizeAnsiForRender = (s: string) =>
s
.replace(ANSI_OSC_RE, '')
.replace(ANSI_STRING_RE, '')
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_CSI_WITH_CMD_RE, (seq, cmd: string) => (cmd === 'm' ? seq : ''))
.replace(ANSI_INCOMPLETE_CSI_RE, '')
.replace(ANSI_NON_CSI_ESC_SEQ_RE, '')
.replace(ANSI_STRAY_ESC_RE, '')
.replace(CONTROL_RE, '')
export const hasAnsi = (s: string) => s.includes(ESC)
const renderEstimateLine = (line: string) => {
const trimmed = line.trim()
if (trimmed.startsWith('|')) {
return trimmed
.split('|')
.filter(Boolean)
.map(cell => cell.trim())
.join(' ')
}
return line
.replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]')
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/(?<!\w)__(.+?)__(?!\w)/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/==(.+?)==/g, '$1')
.replace(/\[\^([^\]]+)\]/g, '[$1]')
.replace(/^#{1,6}\s+/, '')
.replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `)
.replace(/^\s*[-*+]\s+/, '• ')
.replace(/^\s*(\d+)\.\s+/, '$1. ')
.replace(/^\s*(?:>\s*)+/, '│ ')
}
export const compactPreview = (s: string, max: number) => {
const one = s.replace(WS_RE, ' ').trim()
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
}
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
export const edgePreview = (s: string, head = 16, tail = 28) => {
const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]')
return !one
? ''
: one.length <= head + tail + 4
? one
: `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
}
export const pasteTokenLabel = (text: string, lineCount: number) => {
const preview = edgePreview(text)
if (!preview) {
return `[[ [${fmtK(lineCount)} lines] ]]`
}
const [head = preview, tail = ''] = preview.split('.. ', 2)
return tail
? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]`
: `[[ ${preview} [${fmtK(lineCount)} lines] ]]`
}
const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i')
const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu')
export const cleanThinkingText = (reasoning: string) =>
reasoning
.split('\n')
.map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim())
.filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim()))
.join('\n')
.replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
// cleanThinkingText runs several full-string regex passes (split/map/filter/join/replace).
// reasoning grows on every streamed token, so without a pre-bound this re-cleans the whole
// accumulated string on every chunk — O(n) work per token, O(n^2) over a stream. Only the
// tail is ever displayed (boundedLiveRenderText caps it further downstream), so bound the
// input here first. Headroom over LIVE_RENDER_MAX_CHARS keeps line-boundary trimming inside
// cleanThinkingText accurate even after slicing mid-line.
const THINKING_CLEAN_TAIL_BOUND = LIVE_RENDER_MAX_CHARS * 1.5
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
const bounded = reasoning.length > THINKING_CLEAN_TAIL_BOUND ? reasoning.slice(-THINKING_CLEAN_TAIL_BOUND) : reasoning
const raw = cleanThinkingText(bounded)
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
}
export const boundedLiveRenderText = (
text: string,
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
) => boundedRenderText(text, 'showing live tail', { maxChars, maxLines })
const boundedRenderText = (
text: string,
labelPrefix: string,
{ maxChars, maxLines }: { maxChars: number; maxLines: number }
) => {
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
return text
}
let start = 0
let idx = text.length
for (let seen = 0; seen < maxLines && idx > 0; seen++) {
idx = text.lastIndexOf('\n', idx - 1)
start = idx < 0 ? 0 : idx + 1
if (idx < 0) {
break
}
}
const lineStart = start
start = Math.max(lineStart, text.length - maxChars)
if (start > lineStart) {
const nextBreak = text.indexOf('\n', start)
if (nextBreak >= 0 && nextBreak < text.length - 1) {
start = nextBreak + 1
}
}
const tail = text.slice(start).trimStart()
const omittedLines = countNewlines(text, start)
const omittedChars = Math.max(0, text.length - tail.length)
const label =
omittedLines > 0
? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
: `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n`
return `${label}${tail}`
}
const countNewlines = (text: string, end: number) => {
let count = 0
for (let i = 0; i < end; i++) {
if (text.charCodeAt(i) === 10) {
count++
}
}
return count
}
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
export const toolTrailLabel = (name: string) =>
name
.split('_')
.filter(Boolean)
.map(p => p[0]!.toUpperCase() + p.slice(1))
.join(' ') || name
export const formatToolCall = (name: string, context = '') => {
const label = toolTrailLabel(name)
const preview = compactPreview(context, 64)
return preview ? `${label}("${preview}")` : label
}
export const buildToolTrailLine = (
name: string,
context: string,
error?: boolean,
note?: string,
duration?: number
) => {
const detail = compactPreview(note ?? '', 72)
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
}
const verboseToolBlock = (label: string, text?: string) => {
const body = (text ?? '').trim()
// Persisted trail blocks are kept all session and rendered expanded by
// default — cap to a small readable preview (NOT the 16KB live-render
// budget) so a large tool output can't balloon the Ink render tree and
// silently OOM-kill the TUI. See VERBOSE_TRAIL_MAX_CHARS (#34095).
return body
? `${label}:\n${boundedLiveRenderText(body, {
maxChars: VERBOSE_TRAIL_MAX_CHARS,
maxLines: VERBOSE_TRAIL_MAX_LINES
})}`
: ''
}
export const buildVerboseToolTrailLine = (
name: string,
context: string,
error?: boolean,
duration?: number,
argsText?: string,
resultText?: string
) => {
const detail = [verboseToolBlock('Args', argsText), verboseToolBlock(error ? 'Error' : 'Result', resultText)]
.filter(Boolean)
.join('\n')
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
}
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
export const parseToolTrailResultLine = (line: string) => {
if (!isToolTrailResultLine(line)) {
return null
}
const mark = line.endsWith(' ✗') ? '✗' : '✓'
const body = line.slice(0, -2)
const sep = body.indexOf(' :: ')
if (sep >= 0) {
return { call: body.slice(0, sep), detail: body.slice(sep + 4), mark }
}
const legacy = body.indexOf(': ')
if (legacy > 0) {
return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark }
}
return { call: body, detail: '', mark }
}
export const splitToolDuration = (call: string) => {
const match = call.match(/^(.*?)( \(\d+(?:\.\d)?s\))$/)
return match ? { label: match[1]!, duration: match[2]! } : { label: call, duration: '' }
}
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
export const sameToolTrailGroup = (label: string, entry: string) =>
entry === `${label} ✓` ||
entry === `${label} ✗` ||
entry.startsWith(`${label}(`) ||
entry.startsWith(`${label} ::`) ||
entry.startsWith(`${label}:`)
export const lastCotTrailIndex = (trail: readonly string[]) => {
for (let i = trail.length - 1; i >= 0; i--) {
if (!isToolTrailResultLine(trail[i]!)) {
return i
}
}
return -1
}
export const estimateRows = (text: string, w: number, compact = false) => {
let fence: { char: '`' | '~'; len: number } | null = null
let rows = 0
for (const raw of text.split('\n')) {
const line = stripAnsi(raw)
const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
if (maybeFence) {
const marker = maybeFence[1]!
const lang = maybeFence[2]!.trim()
if (!fence) {
fence = { char: marker[0] as '`' | '~', len: marker.length }
if (lang) {
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
}
} else if (marker[0] === fence.char && marker.length >= fence.len) {
fence = null
}
continue
}
const inCode = Boolean(fence)
const trimmed = line.trim()
if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {
continue
}
const rendered = inCode ? line : renderEstimateLine(line)
if (compact && !rendered.trim()) {
continue
}
rows += Math.ceil((rendered.length || 1) / w)
}
return Math.max(1, rows)
}
/**
* Render an unanswered clarify prompt (timed out, or cancelled with Esc/Ctrl+C)
* as a persistent transcript block. The live `ClarifyPrompt` overlay is torn
* down the moment the turn settles, so without this the question + options
* vanish from the screen while the agent's follow-up still refers to "the
* options above". Mirrors the option formatting in ClarifyPrompt (the same
* 1-based numbered list) so the persisted record reads identically to what was
* on screen. `reason` states why the prompt ended ("timed out", "cancelled").
*/
export const formatAbandonedClarify = (question: string, choices: string[] | null, reason: string) => {
const head = `ask ${question.trim()}`
const opts = (choices ?? []).map((c, i) => ` ${i + 1}. ${c}`)
return [head, ...opts, ` (${reason} — no selection)`].join('\n')
}
/**
* Batch counterpart of `formatAbandonedClarify`: every question on its own
* line, answered ones keeping their locked answer (partials survive a
* timeout server-side, so the record must show what was actually sent).
*/
export const formatAbandonedClarifyBatch = (
questions: { qid: string; question: string }[],
answers: Record<string, string>,
reason: string
) => {
const lines = questions.map(q => {
const answer = answers[q.qid]
return answer ? ` ✓ ${q.question} → ${answer}` : ` · ${q.question} (no answer)`
})
return [`ask (${questions.length} questions)`, ...lines, ` (${reason})`].join('\n')
}
/**
* Cursor/draft restore for re-visiting an answered batch clarify question
* (Tab/Shift-Tab): a choice answer puts the cursor back on its row; an
* answer that matches no choice was typed via Other, so the cursor lands on
* the Other row (index = choices.length) with the text staged for editing.
* Unanswered questions restore to a clean cursor.
*/
export const clarifyBatchRevisitState = (
choices: readonly string[],
answer: string | undefined
): { custom: string; sel: number } => {
if (answer === undefined || answer === '') {
return { custom: '', sel: 0 }
}
const choiceIndex = choices.indexOf(answer)
if (choiceIndex >= 0) {
return { custom: '', sel: choiceIndex }
}
return { custom: answer, sel: choices.length > 0 ? choices.length : 0 }
}
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
export const isPasteBackedText = (text: string) =>
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)
+209
View File
@@ -0,0 +1,209 @@
/**
* Flash-free theme boot — the TUI port of the desktop app's
* `hermes-boot-background` / `hermes-boot-color-scheme` localStorage keys.
*
* Theme resolution is asynchronous by nature (gateway skin arrives after
* connect; the OSC-11 background probe answers after the first frame; the
* config mode pin arrives with config sync), so without a cache every launch
* repaints through default-dark → skin → detected-mode. This module persists
* the LAST RESOLVED theme + background to disk and replays them as the very
* first frame, so a stable setup renders correctly from paint one and the
* async signals merely confirm it.
*
* The cache is a hint, never an authority: explicit env pins beat it, and
* every later signal overwrites it (then persists the new answer).
*/
import { readFileSync, renameSync, writeFileSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import type { Theme } from '../theme.js'
interface BootThemeFile {
/** The resolved background hex that detection settled on, if any. */
background?: string
/** The config mode pin (`display.tui_theme`) active when this cache was
* written. Without it a pinned theme caches incoherently — a light
* resolved theme next to the dark PHYSICAL background — and the next
* launch flashes light → dark (skin resolves against the seeded
* background) → light (config pin rehydrates). */
mode?: 'dark' | 'light'
/** The fully-resolved Theme (palette + brand) from the last session. */
theme?: Theme
version: 1
}
// Profile-aware: the Python launcher exports HERMES_HOME (set by
// _apply_profile_override) before spawning the TUI. Falling back to
// ~/.hermes matches get_hermes_home()'s default.
const bootFilePath = () => join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'tui-theme-boot.json')
// Never touch the user's real ~/.hermes from test runs (the TS suite has no
// HERMES_HOME isolation fixture).
const isTestRun = () => !!process.env.VITEST || process.env.NODE_ENV === 'test'
const looksLikeTheme = (value: unknown): value is Theme => {
if (typeof value !== 'object' || value === null) {
return false
}
const theme = value as Partial<Theme>
return (
typeof theme.color === 'object' &&
theme.color !== null &&
typeof theme.color.text === 'string' &&
typeof theme.color.primary === 'string' &&
typeof theme.brand === 'object' &&
theme.brand !== null &&
typeof theme.brand.name === 'string'
)
}
export interface BootTheme {
background?: string
mode?: 'dark' | 'light'
theme: Theme
}
/** Read the cached boot theme. Null on first launch / damage / test runs. */
export function readBootTheme(): BootTheme | null {
if (isTestRun()) {
return null
}
try {
const raw = JSON.parse(readFileSync(bootFilePath(), 'utf8')) as BootThemeFile
if (raw.version !== 1 || !looksLikeTheme(raw.theme)) {
return null
}
return {
background: typeof raw.background === 'string' ? raw.background : undefined,
mode: raw.mode === 'light' || raw.mode === 'dark' ? raw.mode : undefined,
theme: raw.theme
}
} catch {
return null
}
}
let writeTimer: NodeJS.Timeout | null = null
/** Persist the resolved theme (debounced, atomic, fire-and-forget). */
export function writeBootTheme(theme: Theme, background?: string, mode?: 'dark' | 'light'): void {
if (isTestRun()) {
return
}
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
try {
const payload: BootThemeFile = { background, mode, theme, version: 1 }
const path = bootFilePath()
const tmp = `${path}.tmp`
writeFileSync(tmp, JSON.stringify(payload))
renameSync(tmp, path)
} catch {
// Cache write failures are cosmetic — next launch just flashes once.
}
}, 400)
writeTimer.unref?.()
}
// ── Boot-time seeding (with provenance) ──────────────────────────────
//
// The cache seeds env slots so the first skin resolution matches the last
// session — but a previous-session background must NOT occupy the same
// authoritative slot as a current OSC answer forever. Provenance is
// recorded so that once the CURRENT terminal answers with a distrusted
// value (pure-black OSC-11, unusable OSC-10), the stale hint can be
// invalidated and detection falls through to foreground / COLORFGBG /
// macOS appearance / the default — instead of a light cache pinning a now-
// dark terminal to light indefinitely (review on #20379, finding 2).
export interface BootSeedResult {
/** The background hex this boot seeded into env, or null. */
seededBackground: null | string
/** True when the cached config pin was seeded into HERMES_TUI_THEME. */
seededPin: boolean
}
// Provenance of the last seeding pass — read by invalidateBootBackground.
// Module-load seeds process.env; tests re-seed against a fake env.
let seeded: BootSeedResult = { seededBackground: null, seededPin: false }
/** Seeding step (exported for tests — module-load runs it on process.env).
* Explicit user signals always outrank the cache. Records provenance so
* invalidateBootBackground can later demote the hint. */
export function seedBootEnvironment(boot: BootTheme | null, env: NodeJS.ProcessEnv): BootSeedResult {
const result: BootSeedResult = { seededBackground: null, seededPin: false }
seeded = result
if (!boot || env.HERMES_TUI_THEME || env.HERMES_TUI_LIGHT) {
return result
}
// Replay the config mode pin first: a pinned session caches a resolved
// theme whose polarity intentionally disagrees with the physical
// background, and without the pin the first skin resolution flips to the
// physical pole before config hydration flips it back (multi-stage flash).
if (boot.mode) {
env.HERMES_TUI_THEME = boot.mode
result.seededPin = true
}
if (
boot.background &&
// Never seed the untrusted "unset default" fingerprint — a cache written
// before the distrust rule existed must not poison this session's
// detection (it would also suppress the macOS-appearance fallback).
boot.background.toLowerCase() !== '#000000' &&
!env.HERMES_TUI_BACKGROUND
) {
env.HERMES_TUI_BACKGROUND = boot.background
result.seededBackground = boot.background
}
return result
}
/**
* Invalidate the cache-seeded background: called when the CURRENT terminal
* answered the background probe with an untrusted value, meaning the seeded
* hint is from another era and must not outrank the live fallback chain.
* Only clears the slot while it still holds the seeded value — a trusted
* OSC answer or explicit export that overwrote it is left alone.
* Returns true when the slot was cleared.
*/
export function invalidateBootBackground(env: NodeJS.ProcessEnv = process.env): boolean {
if (!seeded.seededBackground || env.HERMES_TUI_BACKGROUND !== seeded.seededBackground) {
return false
}
delete env.HERMES_TUI_BACKGROUND
seeded.seededBackground = null
return true
}
const boot = readBootTheme()
/** True when this boot replayed a cached config pin into HERMES_TUI_THEME.
* applyConfiguredTuiTheme treats it as config-owned so a later 'auto' can
* clear it — otherwise a stale cached pin masquerades as a user shell
* export and becomes unclearable. */
export const bootSeededPin: boolean = seedBootEnvironment(boot, process.env).seededPin
/** The cached theme for the first frame, or null on first launch. */
export const bootTheme: Theme | null = boot?.theme ?? null
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { todoGlyph, todoTone, todoTree } from './todo.js'
describe('todoGlyph', () => {
it('uses fixed-width ASCII markers so the active row does not render wide or emoji-like', () => {
expect(todoGlyph('completed')).toBe('[x]')
expect(todoGlyph('in_progress')).toBe('[>]')
expect(todoGlyph('pending')).toBe('[ ]')
expect(todoGlyph('cancelled')).toBe('[-]')
})
})
describe('todoTone', () => {
it('keeps todo status rows neutral instead of red/green', () => {
expect(todoTone('completed')).toBe('dim')
expect(todoTone('cancelled')).toBe('dim')
expect(todoTone('pending')).toBe('body')
expect(todoTone('in_progress')).toBe('active')
})
})
describe('todoTree', () => {
it('orders parents before children with depths', () => {
const tree = todoTree([
{ content: 'WP1', id: 'wp1', status: 'in_progress' },
{ content: 'WP2', id: 'wp2', status: 'pending' },
{ content: 'T1', id: 't1', parent: 'wp1', status: 'pending' },
{ content: 'T2', id: 't2', parent: 'wp1', status: 'pending' }
])
expect(tree.map(([t, d]) => [t.id, d])).toEqual([
['wp1', 0],
['t1', 1],
['t2', 1],
['wp2', 0]
])
})
it('degrades dangling and self parents to roots', () => {
const tree = todoTree([
{ content: 'A', id: 'a', parent: 'ghost', status: 'pending' },
{ content: 'B', id: 'b', parent: 'b', status: 'pending' }
])
expect(tree.map(([t, d]) => [t.id, d])).toEqual([
['a', 0],
['b', 0]
])
})
it('keeps cycle members instead of dropping them', () => {
const tree = todoTree([
{ content: 'A', id: 'a', parent: 'b', status: 'pending' },
{ content: 'B', id: 'b', parent: 'a', status: 'pending' }
])
expect(tree.map(([t]) => t.id).sort()).toEqual(['a', 'b'])
})
it('flattens a todo list with no parents unchanged, all at depth 0', () => {
const tree = todoTree([
{ content: 'A', id: 'a', status: 'pending' },
{ content: 'B', id: 'b', status: 'completed' }
])
expect(tree.map(([t, d]) => [t.id, d])).toEqual([
['a', 0],
['b', 0]
])
})
})
+59
View File
@@ -0,0 +1,59 @@
import type { TodoItem } from '../types.js'
export type TodoTone = 'active' | 'body' | 'dim'
export const todoGlyph = (status: TodoItem['status']) =>
status === 'completed' ? '[x]' : status === 'cancelled' ? '[-]' : status === 'in_progress' ? '[>]' : '[ ]'
export const todoTone = (status: TodoItem['status']): TodoTone =>
status === 'in_progress' ? 'active' : status === 'pending' ? 'body' : 'dim'
/** DFS order of a (possibly nested) todo list: [item, depth] pairs, parents
* before children. Dangling/cyclic parents degrade to depth 0. Mirrors
* apps/desktop/src/lib/todos.ts's todoTree() so both surfaces render the
* same hierarchy from the same `parent` field. */
export function todoTree(todos: readonly TodoItem[]): [TodoItem, number][] {
const ids = new Set(todos.map(t => t.id))
const kids = new Map<string, TodoItem[]>()
const roots: TodoItem[] = []
for (const t of todos) {
if (t.parent && ids.has(t.parent) && t.parent !== t.id) {
const list = kids.get(t.parent) ?? []
list.push(t)
kids.set(t.parent, list)
} else {
roots.push(t)
}
}
const out: [TodoItem, number][] = []
const seen = new Set<string>()
const walk = (item: TodoItem, depth: number) => {
if (seen.has(item.id)) {
return
}
seen.add(item.id)
out.push([item, depth])
for (const kid of kids.get(item.id) ?? []) {
walk(kid, depth + 1)
}
}
for (const root of roots) {
walk(root, 0)
}
// Cycle members never reach a root — append them flat so nothing is lost.
for (const t of todos) {
if (!seen.has(t.id)) {
seen.add(t.id)
out.push([t, 0])
}
}
return out
}
+136
View File
@@ -0,0 +1,136 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import type { RefObject } from 'react'
import { useCallback, useMemo, useSyncExternalStore } from 'react'
export interface ViewportSnapshot {
atBottom: boolean
bottom: number
pending: number
scrollHeight: number
top: number
viewportHeight: number
}
export interface ScrollbarSnapshot {
scrollHeight: number
top: number
viewportHeight: number
}
const EMPTY: ViewportSnapshot = {
atBottom: true,
bottom: 0,
pending: 0,
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
const EMPTY_SCROLLBAR: ScrollbarSnapshot = {
scrollHeight: 0,
top: 0,
viewportHeight: 0
}
export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapshot {
if (!s) {
return EMPTY
}
const pending = s.getPendingDelta()
const top = Math.max(0, s.getScrollTop() + pending)
const viewportHeight = Math.max(0, s.getViewportHeight())
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
let scrollHeight = cachedScrollHeight
const bottom = top + viewportHeight
let atBottom = s.isSticky() || bottom >= scrollHeight - 2
if (!atBottom) {
scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
atBottom = s.isSticky() || bottom >= scrollHeight - 2
}
return {
atBottom,
bottom,
pending,
scrollHeight,
top,
viewportHeight
}
}
export function viewportSnapshotKey(v: ViewportSnapshot) {
return `${v.atBottom ? 1 : 0}:${Math.ceil(v.top / 8) * 8}:${v.viewportHeight}:${Math.ceil(v.scrollHeight / 8) * 8}:${v.pending}`
}
export function getScrollbarSnapshot(s?: ScrollBoxHandle | null): ScrollbarSnapshot {
if (!s) {
return EMPTY_SCROLLBAR
}
const viewportHeight = Math.max(0, s.getViewportHeight())
const top = Math.max(0, s.getScrollTop())
const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight())
let scrollHeight = cachedScrollHeight
let maxTop = Math.max(0, scrollHeight - viewportHeight)
if (top < maxTop) {
const freshScrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight)
const freshMaxTop = Math.max(0, freshScrollHeight - viewportHeight)
if (top >= freshMaxTop) {
scrollHeight = freshScrollHeight
maxTop = freshMaxTop
}
}
return {
scrollHeight,
top: Math.max(0, Math.min(maxTop, top)),
viewportHeight
}
}
export function scrollbarSnapshotKey(v: ScrollbarSnapshot) {
return `${v.top}:${v.viewportHeight}:${v.scrollHeight}`
}
export function useViewportSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ViewportSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => viewportSnapshotKey(getViewportSnapshot(scrollRef.current)),
() => viewportSnapshotKey(EMPTY)
)
return useMemo(() => {
const [atBottom = '1', top = '0', viewportHeight = '0', scrollHeight = '0', pending = '0'] = key.split(':')
return {
atBottom: atBottom === '1',
bottom: Number(top) + Number(viewportHeight),
pending: Number(pending),
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}
export function useScrollbarSnapshot(scrollRef: RefObject<ScrollBoxHandle | null>): ScrollbarSnapshot {
const key = useSyncExternalStore(
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
() => scrollbarSnapshotKey(getScrollbarSnapshot(scrollRef.current)),
() => scrollbarSnapshotKey(EMPTY_SCROLLBAR)
)
return useMemo(() => {
const [top = '0', viewportHeight = '0', scrollHeight = '0'] = key.split(':')
return {
scrollHeight: Number(scrollHeight),
top: Number(top),
viewportHeight: Number(viewportHeight)
}
}, [key])
}
+162
View File
@@ -0,0 +1,162 @@
import { TERMUX_TUI_MODE } from '../config/env.js'
import type { Msg } from '../types.js'
import { transcriptBodyWidth } from './inputMetrics.js'
const hashText = (text: string) => {
let h = 5381
for (let i = 0; i < text.length; i++) {
h = ((h << 5) + h) ^ text.charCodeAt(i)
}
return (h >>> 0).toString(36)
}
export const messageHeightKey = (msg: Msg) => {
const todoSig = msg.todos?.map(t => `${t.status}:${t.content}`).join('\u0001') ?? ''
const panelSig =
msg.panelData?.sections
.map(s => `${s.title ?? ''}:${s.text?.length ?? 0}:${s.items?.length ?? 0}:${s.rows?.length ?? 0}`)
.join('\u0001') ?? ''
const introSig = msg.kind === 'intro' ? (msg.info?.version ?? '') : ''
return [
msg.role,
msg.kind ?? '',
hashText([msg.text, msg.thinking ?? '', msg.tools?.join('\n') ?? '', todoSig, panelSig, introSig].join('\0'))
].join(':')
}
// Hard cap on rows the estimator will count. Each row above this is
// invisible to the estimator (gets clipped to MAX_ESTIMATE_LINES), but
// post-mount Yoga measurement converges to the real height on first
// render. Without this, a long assistant turn (10k+ chars) costs O(text)
// per offset rebuild × every uncached item — cold-mounting a 1000-row
// transcript becomes a multi-million-char wrap walk that blocks the UI.
//
// 800 covers any realistic assistant message (the prior history-clip
// ceiling was 16 lines, then full text — this is the sane middle).
const MAX_ESTIMATE_LINES = 800
export const wrappedLines = (text: string, width: number, maxLines: number = MAX_ESTIMATE_LINES) => {
const w = Math.max(1, width)
// Worst case: every cell is its own row at width=1, plus a small
// slack for the trailing partial line. Walking past this byte budget
// cannot increase n any further once n is already past maxLines, so
// bail. Saves O(text) walks on multi-megabyte single-line messages.
const budget = Math.min(text.length, maxLines * w + maxLines)
let n = 0
let start = 0
for (let i = 0; i <= budget; i++) {
if (i === text.length || i === budget || text.charCodeAt(i) === 10) {
const rows = Math.max(1, Math.ceil((i - start) / w))
n += rows >= maxLines - n ? maxLines - n : rows
start = i + 1
if (n >= maxLines) {
return maxLines
}
}
}
return n
}
export const estimatedMsgHeight = (
msg: Msg,
cols: number,
{
compact,
details,
leadGap = false,
thinkingVisible = details,
thinkingExpanded = thinkingVisible,
toolsVisible = details,
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
leadGap?: boolean
thinkingExpanded?: boolean
thinkingVisible?: boolean
toolsVisible?: boolean
userPrompt?: string
withSeparator?: boolean
}
) => {
if (msg.kind === 'intro') {
return msg.info?.version ? 9 : 5
}
if (msg.kind === 'panel') {
return Math.max(3, (msg.panelData?.sections.length ?? 1) * 2 + 1)
}
if (msg.kind === 'trail' && msg.todos?.length) {
if (msg.todoCollapsedByDefault) {
return 2
}
return Math.max(2, msg.todos.length + 2)
}
const bodyWidth = transcriptBodyWidth(cols, msg.role, userPrompt, TERMUX_TUI_MODE)
const text = msg.text
let h = wrappedLines(text || ' ', bodyWidth)
if (!compact && msg.role === 'assistant') {
// Paragraph gaps add up to 6 extra rows of breathing room. Slice
// first so the regex never walks more than the first ~16k chars of
// a giant assistant message — post-mount Yoga measurement converges
// to the real height regardless of how the estimate undercounts.
const scan = text.length > 16_000 ? text.slice(0, 16_000) : text
h += Math.min(6, (scan.match(/\n\s*\n/g) ?? []).length)
}
if (details) {
const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length)
const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '')
const hasVisibleDetails = hasVisibleTools || hasVisibleThinking
if (hasVisibleDetails) {
h +=
(hasVisibleTools ? (msg.tools?.length ?? 0) : 0) +
(hasVisibleThinking ? (thinkingExpanded ? wrappedLines(msg.thinking ?? '', bodyWidth) : 1) : 0)
if (msg.role === 'assistant' && /\S/.test(msg.text)) {
h += 2
}
}
}
if (msg.role === 'user' || msg.kind === 'diff') {
// Top + bottom blank line.
h += 2
} else if (msg.kind === 'slash') {
h++
}
// Group-boundary blank line owned by BlockSlot: model prose, reasoning/tool
// trails, and notes/errors each start a new visual group when the block
// above them is a different kind. The caller resolves the boundary against
// the previous row (see domain/blockLayout.ts::hasLeadGap) and passes the
// result here so the estimate matches the rendered marginTop before Yoga
// remeasures. user / diff / slash never set this — they own their margins.
if (leadGap) {
h++
}
// Inter-turn separator above non-first user messages (1 rule row + 1
// top-margin row). The render-side gate is in appLayout.tsx; we trust
// the caller to pass `withSeparator` only when it matches that gate.
if (withSeparator) {
h += 2
}
return Math.max(1, h)
}
+190
View File
@@ -0,0 +1,190 @@
// Wheel-scroll acceleration state machine.
//
// One event = 1 row feels sluggish on trackpads (200+ ev/s) and sustained
// mouse-wheel; one event = 6 rows teleports and ruins precision.
// Heuristic on inter-event gap + direction flips:
//
// gap < 5ms → same-batch burst → 1 row/event
// gap < 40ms (native) → ramp +0.3, cap 6
// gap 80-500ms (xterm.js) → mult = 1 + (mult-1)·0.5^(gap/150) + 5·decay
// cap 3 slow / 6 fast
// gap > 500ms → reset (deliberate click stays responsive)
// flip + flip-back ≤200ms → encoder bounce → engage wheel-mode (sticky cap)
// 5 consecutive <5ms events → trackpad flick → disengage wheel-mode
//
// Native terminals (Ghostty, iTerm2) and xterm.js embedders (VS Code,
// Cursor) emit wheel events with different cadences, hence two paths.
import { isXtermJs } from '@hermes/ink'
// ── Native (ghostty, iTerm2, WezTerm, …) ───────────────────────────────
const WHEEL_ACCEL_WINDOW_MS = 40
const WHEEL_ACCEL_STEP = 0.3
const WHEEL_ACCEL_MAX = 6
// ── Encoder bounce / wheel-mode (mechanical wheels) ────────────────────
const WHEEL_BOUNCE_GAP_MAX_MS = 200
const WHEEL_MODE_STEP = 15
const WHEEL_MODE_CAP = 15
const WHEEL_MODE_RAMP = 3
const WHEEL_MODE_IDLE_DISENGAGE_MS = 1500
// ── xterm.js (VS Code / Cursor / browser terminals) ────────────────────
const WHEEL_DECAY_HALFLIFE_MS = 150
const WHEEL_DECAY_STEP = 5
const WHEEL_BURST_MS = 5
const WHEEL_DECAY_GAP_MS = 80
const WHEEL_DECAY_CAP_SLOW = 3
const WHEEL_DECAY_CAP_FAST = 6
const WHEEL_DECAY_IDLE_MS = 500
export type WheelAccelState = {
time: number
mult: number
dir: 0 | 1 | -1
xtermJs: boolean
/** Carried fractional scroll (xterm.js). scrollBy floors, so without
* this a mult of 1.5 always gives 1 row; carrying the remainder gives
* 1,2,1,2 — correct throughput over time. */
frac: number
/** Native baseline rows/event. Reset on idle/reversal; ramp builds on
* top. xterm.js path ignores. */
base: number
/** Deferred direction flip (native): bounce vs reversal — next event
* decides. */
pendingFlip: boolean
/** Sticky once a flip-then-flip-back fires within the bounce window.
* Cleared by idle disengage or trackpad burst. */
wheelMode: boolean
/** Consecutive <5ms events. ≥5 → trackpad flick → disengage. */
burstCount: number
}
export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState {
return { burstCount: 0, base, dir: 0, frac: 0, mult: base, pendingFlip: false, time: 0, wheelMode: false, xtermJs }
}
/** HERMES_TUI_SCROLL_SPEED (or CLAUDE_CODE_SCROLL_SPEED for portability).
* Default 1, clamped (0, 20]. */
export function readScrollSpeedBase(): number {
const n = parseFloat(process.env.HERMES_TUI_SCROLL_SPEED ?? process.env.CLAUDE_CODE_SCROLL_SPEED ?? '')
return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 1
}
export function initWheelAccelForHost(): WheelAccelState {
return initWheelAccel(isXtermJs(), readScrollSpeedBase())
}
/** Compute rows for one wheel event, mutating `state`. Returns 0 when a
* direction flip is deferred for bounce detection — call sites should
* no-op on 0. */
export function computeWheelStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
return state.xtermJs ? xtermJsStep(state, dir, now) : nativeStep(state, dir, now)
}
function nativeStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
// Idle disengage runs first so a pending bounce can't mask "user paused
// 1.5s then mouse-clicked" as a real reversal.
if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) {
state.wheelMode = false
state.burstCount = 0
state.mult = state.base
}
if (state.pendingFlip) {
state.pendingFlip = false
if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) {
// Real reversal (flip persisted OR flip-back too late). Commit.
// The deferred event's 1 row is lost — acceptable latency.
state.dir = dir
state.time = now
state.mult = state.base
return Math.floor(state.mult)
}
state.wheelMode = true
}
const gap = now - state.time
if (dir !== state.dir && state.dir !== 0) {
state.pendingFlip = true
state.time = now
return 0
}
state.dir = dir
state.time = now
if (state.wheelMode) {
if (gap < WHEEL_BURST_MS) {
// Same-batch burst (SGR proportional) OR trackpad flick. 1 row/event;
// trackpad flick trips the burst-count disengage.
if (++state.burstCount >= 5) {
state.wheelMode = false
state.burstCount = 0
state.mult = state.base
} else {
return 1
}
} else {
state.burstCount = 0
}
}
if (state.wheelMode) {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = Math.max(WHEEL_MODE_CAP, state.base * 2)
const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m
state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP)
return Math.floor(state.mult)
}
// Trackpad / hi-res native: tight 40ms window — sub-window ramps,
// anything slower resets to baseline.
if (gap > WHEEL_ACCEL_WINDOW_MS) {
state.mult = state.base
} else {
const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2)
state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP)
}
return Math.floor(state.mult)
}
function xtermJsStep(state: WheelAccelState, dir: -1 | 1, now: number): number {
const gap = now - state.time
const sameDir = dir === state.dir
state.time = now
state.dir = dir
if (sameDir && gap < WHEEL_BURST_MS) {
return 1
}
if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) {
// Reversal or long idle — start at 2 so first click after a pause moves visibly.
state.mult = 2
state.frac = 0
} else {
const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS)
const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST
state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m)
}
const total = state.mult + state.frac
const rows = Math.floor(total)
state.frac = total - rows
return rows
}
+510
View File
@@ -0,0 +1,510 @@
export interface WidgetGridItem {
colSpan?: number
colStart?: number
id: string
span?: number
}
export interface WidgetGridCell {
col: number
id: string
span: number
width: number
}
export interface WidgetGridLayout {
columnCount: number
columns: number[]
rows: WidgetGridCell[][]
}
export interface WidgetGridLayoutOptions {
/**
* Explicit column count (equal shares) or a grid-template-style track list
* (fixed cell counts / weighted `fr` shares). Omitted: auto from width.
*/
columns?: GridTrackSize[] | number
gap?: number
items: WidgetGridItem[]
maxColumns?: number
minColumnWidth?: number
width: number
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const toInt = (value: number, fallback: number) => {
if (!Number.isFinite(value)) {
return fallback
}
return Math.trunc(value)
}
const columnCountForWidth = (width: number, minColumnWidth: number, gap: number, maxColumns: number) => {
const safeWidth = Math.max(1, toInt(width, 1))
const safeMinWidth = Math.max(1, toInt(minColumnWidth, 1))
const safeGap = Math.max(0, toInt(gap, 0))
const safeMaxColumns = Math.max(1, toInt(maxColumns, 1))
const count = Math.floor((safeWidth + safeGap) / (safeMinWidth + safeGap))
return clamp(count || 1, 1, safeMaxColumns)
}
const buildColumnWidths = (width: number, columnCount: number, gap: number) =>
resolveGridTracks(
width,
gap,
Array.from({ length: Math.max(1, toInt(columnCount, 1)) }, () => ({ fr: 1 }))
)
const spanWidth = (columns: number[], colStart: number, span: number, gap: number) => {
const end = Math.min(columns.length, colStart + span)
const width = columns.slice(colStart, end).reduce((acc, value) => acc + value, 0)
const safeGap = Math.max(0, toInt(gap, 0))
return width + safeGap * Math.max(0, end - colStart - 1)
}
export const widgetGridSpanWidth = spanWidth
const itemSpan = (item: WidgetGridItem, columnCount: number) =>
clamp(toInt(item.colSpan ?? item.span ?? 1, 1), 1, columnCount)
const itemColStart = (item: WidgetGridItem, columnCount: number, span: number) => {
if (item.colStart === undefined) {
return null
}
return clamp(toInt(item.colStart, 0), 0, Math.max(0, columnCount - span))
}
const rangeIsFree = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
if (occupied[col]) {
return false
}
}
return true
}
const occupyRange = (occupied: boolean[], colStart: number, span: number) => {
for (let col = colStart; col < colStart + span; col++) {
occupied[col] = true
}
}
const firstFreeCol = (occupied: boolean[], span: number) => {
for (let col = 0; col <= occupied.length - span; col++) {
if (rangeIsFree(occupied, col, span)) {
return col
}
}
return null
}
const sortRow = (row: WidgetGridCell[]) => row.sort((a, b) => a.col - b.col)
export function layoutWidgetGrid({
columns: requestedColumns,
gap = 1,
items,
maxColumns = 3,
minColumnWidth = 28,
width
}: WidgetGridLayoutOptions): WidgetGridLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeWidth = Math.max(1, toInt(width, 1))
const maxDrawableColumns = safeGap > 0 ? Math.max(1, Math.floor((safeWidth + safeGap) / (safeGap + 1))) : safeWidth
const trackList = Array.isArray(requestedColumns) && requestedColumns.length ? requestedColumns : null
const columnCount = trackList
? trackList.length
: requestedColumns === undefined || Array.isArray(requestedColumns)
? columnCountForWidth(safeWidth, minColumnWidth, safeGap, maxColumns)
: clamp(toInt(requestedColumns, 1), 1, maxDrawableColumns)
const columns = trackList
? resolveGridTracks(safeWidth, safeGap, trackList)
: buildColumnWidths(width, columnCount, safeGap)
const rows: WidgetGridCell[][] = []
let row: WidgetGridCell[] = []
let occupied = Array.from({ length: columnCount }, () => false)
const pushRow = () => {
rows.push(sortRow(row))
row = []
occupied = Array.from({ length: columnCount }, () => false)
}
for (const item of items) {
const wantedSpan = itemSpan(item, columnCount)
const explicitCol = itemColStart(item, columnCount, wantedSpan)
let col = explicitCol ?? firstFreeCol(occupied, wantedSpan)
if (col === null || (explicitCol !== null && !rangeIsFree(occupied, explicitCol, wantedSpan))) {
if (row.length > 0) {
pushRow()
}
col = explicitCol ?? 0
}
row.push({
col,
id: item.id,
span: wantedSpan,
width: spanWidth(columns, col, wantedSpan, safeGap)
})
occupyRange(occupied, col, wantedSpan)
}
if (row.length > 0) {
rows.push(sortRow(row))
}
return { columnCount, columns, rows }
}
// ── Track solver (grid-template-columns/rows for character cells) ──────────
//
// A track is either a fixed cell count (`number`) or a fractional share
// (`{ fr, min? }`) of whatever space the fixed tracks leave over — the same
// fixed-vs-flex split the desktop pane-shell's track model uses, solved in
// integer terminal cells. `min` defaults to 1 so a track never disappears.
export type GridTrackSize = number | { fr: number; min?: number }
const trackFr = (track: GridTrackSize) => (typeof track === 'number' ? 0 : Math.max(0.0001, track.fr))
const trackMin = (track: GridTrackSize) => (typeof track === 'number' ? 1 : Math.max(1, toInt(track.min ?? 1, 1)))
/** Floor-divide `budget` across `weights`, spreading the remainder left-to-right. */
const distributeByWeight = (budget: number, weights: number[]) => {
const total = weights.reduce((acc, w) => acc + w, 0)
if (total <= 0 || budget <= 0) {
return weights.map(() => 0)
}
const shares = weights.map(w => Math.floor((budget * w) / total))
let remainder = budget - shares.reduce((acc, s) => acc + s, 0)
for (let i = 0; remainder > 0 && i < shares.length; i++, remainder--) {
shares[i]! += 1
}
return shares
}
/**
* Solve track sizes for a `total`-cell axis with `gap` cells between tracks.
* Fixed tracks take their size; `fr` tracks share the leftover by weight,
* re-pinning any track that falls below its `min` and re-solving the rest.
* Every track ends up ≥ 1 cell; when the axis genuinely can't fit, the
* overflow is shaved off the trailing tracks so the sum never exceeds the
* drawable width unless every track is already at 1.
*/
export function resolveGridTracks(total: number, gap: number, tracks: GridTrackSize[]): number[] {
const count = tracks.length
if (!count) {
return []
}
const safeGap = Math.max(0, toInt(gap, 0))
const usable = Math.max(count, toInt(total, count) - safeGap * (count - 1))
const sizes = Array.from({ length: count }, () => 0)
let remaining = usable
let unpinned: number[] = []
tracks.forEach((track, idx) => {
if (typeof track === 'number') {
sizes[idx] = Math.max(1, toInt(track, 1))
remaining -= sizes[idx]!
} else {
unpinned.push(idx)
}
})
while (unpinned.length) {
const shares = distributeByWeight(
Math.max(0, remaining),
unpinned.map(idx => trackFr(tracks[idx]!))
)
const violating = unpinned.filter((idx, i) => shares[i]! < trackMin(tracks[idx]!))
if (!violating.length) {
unpinned.forEach((idx, i) => {
sizes[idx] = shares[i]!
})
break
}
for (const idx of violating) {
sizes[idx] = trackMin(tracks[idx]!)
remaining -= sizes[idx]!
}
unpinned = unpinned.filter(idx => !violating.includes(idx))
}
let overflow = sizes.reduce((acc, s) => acc + s, 0) - usable
for (let idx = count - 1; idx >= 0 && overflow > 0; idx--) {
const give = Math.min(overflow, sizes[idx]! - 1)
sizes[idx] -= give
overflow -= give
}
return sizes
}
// ── 2D area layout (the workspace mode) ────────────────────────────────────
//
// `layoutGridAreas` is the full two-axis grid: explicit column AND row tracks,
// items with `col`/`row` pins and `colSpan`/`rowSpan`, dense first-fit
// auto-placement, and each cell solved to an absolute `{ x, y, width, height }`
// rect. It is the terminal-cell equivalent of the desktop zone editor's
// `GridLayout` (rowPercents / columnPercents / cellChildMap with merged
// spans) — the renderer absolutely positions each rect, which is what makes
// `rowSpan` representable at all under Yoga flexbox.
export interface GridAreaItem {
id: string
/** Explicit column start (0-based). Explicitly pinned items may overlap. */
col?: number
colSpan?: number
/** Explicit row start (0-based). Rows grow implicitly as needed. */
row?: number
rowSpan?: number
}
export interface GridAreaCell {
col: number
colSpan: number
height: number
id: string
row: number
rowSpan: number
width: number
x: number
y: number
}
export interface GridAreasLayout {
cells: GridAreaCell[]
columnCount: number
columnSizes: number[]
height: number
rowCount: number
rowSizes: number[]
width: number
}
export interface GridAreasOptions {
/** Column tracks — a count (equal `fr` shares) or explicit track list. */
columns: GridTrackSize[] | number
gap?: number
height: number
items: GridAreaItem[]
rowGap?: number
/** Row tracks. Omitted or short: implicit rows are `{ fr: 1 }`. */
rows?: GridTrackSize[] | number
width: number
}
const normalizeTracks = (tracks: GridTrackSize[] | number | undefined, fallbackCount: number): GridTrackSize[] => {
if (Array.isArray(tracks) && tracks.length) {
return tracks
}
const count = Math.max(1, toInt(typeof tracks === 'number' ? tracks : fallbackCount, 1))
return Array.from({ length: count }, () => ({ fr: 1 }))
}
interface PlacedArea {
col: number
colSpan: number
id: string
row: number
rowSpan: number
}
const ensureOccupancyRows = (occupied: boolean[][], rowCount: number, columnCount: number) => {
while (occupied.length < rowCount) {
occupied.push(Array.from({ length: columnCount }, () => false))
}
}
const areaIsFree = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
if (occupied[r]![c]) {
return false
}
}
}
return true
}
const occupyArea = (occupied: boolean[][], row: number, col: number, rowSpan: number, colSpan: number) => {
ensureOccupancyRows(occupied, row + rowSpan, occupied[0]?.length ?? 1)
for (let r = row; r < row + rowSpan; r++) {
for (let c = col; c < col + colSpan; c++) {
occupied[r]![c] = true
}
}
}
/** Dense first-fit auto-placement (CSS `grid-auto-flow: row dense`). */
const placeGridItems = (items: GridAreaItem[], columnCount: number): PlacedArea[] => {
const occupied: boolean[][] = [Array.from({ length: columnCount }, () => false)]
return items.map(item => {
const colSpan = clamp(toInt(item.colSpan ?? 1, 1), 1, columnCount)
const rowSpan = Math.max(1, toInt(item.rowSpan ?? 1, 1))
const pinnedCol = item.col === undefined ? null : clamp(toInt(item.col, 0), 0, columnCount - colSpan)
const pinnedRow = item.row === undefined ? null : Math.max(0, toInt(item.row, 0))
let row: number
let col: number
if (pinnedRow !== null && pinnedCol !== null) {
row = pinnedRow
col = pinnedCol
} else if (pinnedRow !== null) {
// Pinned row: first free column run, overlapping at col 0 when full.
col = 0
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, pinnedRow, c, rowSpan, colSpan)) {
col = c
break
}
}
row = pinnedRow
} else {
// Auto (or pinned col): scan row-major for the first fitting rect. New
// rows are always empty, so the scan terminates.
row = 0
col = pinnedCol ?? 0
for (let r = 0; ; r++) {
const found =
pinnedCol !== null
? areaIsFree(occupied, r, pinnedCol, rowSpan, colSpan)
? pinnedCol
: null
: (() => {
for (let c = 0; c <= columnCount - colSpan; c++) {
if (areaIsFree(occupied, r, c, rowSpan, colSpan)) {
return c
}
}
return null
})()
if (found !== null) {
row = r
col = found
break
}
}
}
occupyArea(occupied, row, col, rowSpan, colSpan)
return { col, colSpan, id: item.id, row, rowSpan }
})
}
const trackOffsets = (sizes: number[], gap: number) => {
const offsets: number[] = []
let cursor = 0
for (const size of sizes) {
offsets.push(cursor)
cursor += size + gap
}
return offsets
}
const spanSize = (sizes: number[], start: number, span: number, gap: number) => {
const end = Math.min(sizes.length, start + span)
return sizes.slice(start, end).reduce((acc, s) => acc + s, 0) + gap * Math.max(0, end - start - 1)
}
export function layoutGridAreas({
columns,
gap = 1,
height,
items,
rowGap = 0,
rows,
width
}: GridAreasOptions): GridAreasLayout {
const safeGap = Math.max(0, toInt(gap, 1))
const safeRowGap = Math.max(0, toInt(rowGap, 0))
const safeWidth = Math.max(1, toInt(width, 1))
const safeHeight = Math.max(1, toInt(height, 1))
const columnTracks = normalizeTracks(columns, 1)
const columnCount = columnTracks.length
const placed = placeGridItems(items, columnCount)
const placedRowCount = placed.reduce((acc, area) => Math.max(acc, area.row + area.rowSpan), 0)
const explicitRowTracks = rows === undefined ? [] : normalizeTracks(rows, 1)
const rowCount = Math.max(1, explicitRowTracks.length, placedRowCount)
const rowTracks: GridTrackSize[] = Array.from({ length: rowCount }, (_, idx) => explicitRowTracks[idx] ?? { fr: 1 })
const columnSizes = resolveGridTracks(safeWidth, safeGap, columnTracks)
const rowSizes = resolveGridTracks(safeHeight, safeRowGap, rowTracks)
const columnStarts = trackOffsets(columnSizes, safeGap)
const rowStarts = trackOffsets(rowSizes, safeRowGap)
const cells = placed.map(area => {
const row = Math.min(area.row, rowCount - 1)
return {
col: area.col,
colSpan: area.colSpan,
height: spanSize(rowSizes, row, area.rowSpan, safeRowGap),
id: area.id,
row,
rowSpan: area.rowSpan,
width: spanSize(columnSizes, area.col, area.colSpan, safeGap),
x: columnStarts[area.col] ?? 0,
y: rowStarts[row] ?? 0
}
})
return {
cells,
columnCount,
columnSizes,
height: safeHeight,
rowCount,
rowSizes,
width: safeWidth
}
}