Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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") ✓'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 }]
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(' ')}`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user