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,6 @@
|
||||
export function logForDebugging(
|
||||
_message: string,
|
||||
_options: {
|
||||
level?: string
|
||||
} = {}
|
||||
): void {}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { lastGrapheme } from './intl.js'
|
||||
let earlyInputBuffer = ''
|
||||
let isCapturing = false
|
||||
let readableHandler: (() => void) | null = null
|
||||
|
||||
export function startCapturingEarlyInput(): void {
|
||||
if (!process.stdin.isTTY || isCapturing || process.argv.includes('-p') || process.argv.includes('--print')) {
|
||||
return
|
||||
}
|
||||
|
||||
isCapturing = true
|
||||
earlyInputBuffer = ''
|
||||
|
||||
try {
|
||||
process.stdin.setEncoding('utf8')
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.ref()
|
||||
|
||||
readableHandler = () => {
|
||||
let chunk = process.stdin.read()
|
||||
|
||||
while (chunk !== null) {
|
||||
if (typeof chunk === 'string') {
|
||||
processChunk(chunk)
|
||||
}
|
||||
|
||||
chunk = process.stdin.read()
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('readable', readableHandler)
|
||||
} catch {
|
||||
isCapturing = false
|
||||
}
|
||||
}
|
||||
|
||||
function processChunk(str: string): void {
|
||||
let i = 0
|
||||
|
||||
while (i < str.length) {
|
||||
const char = str[i]!
|
||||
const code = char.charCodeAt(0)
|
||||
|
||||
if (code === 3) {
|
||||
stopCapturingEarlyInput()
|
||||
process.exit(130)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 4) {
|
||||
stopCapturingEarlyInput()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (code === 127 || code === 8) {
|
||||
if (earlyInputBuffer.length > 0) {
|
||||
const last = lastGrapheme(earlyInputBuffer)
|
||||
earlyInputBuffer = earlyInputBuffer.slice(0, -(last.length || 1))
|
||||
}
|
||||
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (code === 27) {
|
||||
i++
|
||||
|
||||
while (i < str.length && !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126)) {
|
||||
i++
|
||||
}
|
||||
|
||||
if (i < str.length) {
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (code === 13) {
|
||||
earlyInputBuffer += '\n'
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
earlyInputBuffer += char
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCapturingEarlyInput(): void {
|
||||
if (!isCapturing) {
|
||||
return
|
||||
}
|
||||
|
||||
isCapturing = false
|
||||
|
||||
if (readableHandler) {
|
||||
process.stdin.removeListener('readable', readableHandler)
|
||||
readableHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeEarlyInput(): string {
|
||||
stopCapturingEarlyInput()
|
||||
const input = earlyInputBuffer.trim()
|
||||
earlyInputBuffer = ''
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
export function hasEarlyInput(): boolean {
|
||||
return earlyInputBuffer.trim().length > 0
|
||||
}
|
||||
|
||||
export function seedEarlyInput(text: string): void {
|
||||
earlyInputBuffer = text
|
||||
}
|
||||
|
||||
export function isCapturingEarlyInput(): boolean {
|
||||
return isCapturing
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
type TerminalName = string | null
|
||||
|
||||
function detectTerminal(): TerminalName {
|
||||
if (process.env.CURSOR_TRACE_ID) {
|
||||
return 'cursor'
|
||||
}
|
||||
|
||||
if (process.env.TERM === 'xterm-ghostty') {
|
||||
return 'ghostty'
|
||||
}
|
||||
|
||||
if (process.env.TERM?.includes('kitty')) {
|
||||
return 'kitty'
|
||||
}
|
||||
|
||||
if (process.env.TERM_PROGRAM) {
|
||||
return process.env.TERM_PROGRAM
|
||||
}
|
||||
|
||||
if (process.env.TMUX) {
|
||||
return 'tmux'
|
||||
}
|
||||
|
||||
if (process.env.STY) {
|
||||
return 'screen'
|
||||
}
|
||||
|
||||
if (process.env.KITTY_WINDOW_ID) {
|
||||
return 'kitty'
|
||||
}
|
||||
|
||||
if (process.env.WT_SESSION) {
|
||||
return 'windows-terminal'
|
||||
}
|
||||
|
||||
return process.env.TERM ?? null
|
||||
}
|
||||
|
||||
export const env = {
|
||||
terminal: detectTerminal()
|
||||
}
|
||||
|
||||
// Terminals known to correctly implement OSC 52 clipboard writes
|
||||
// (ESC ] 52 ; c ; <b64> BEL/ST — osc() in ink/termio/osc.ts emits BEL
|
||||
// for most terminals and ST for kitty). When detected, setClipboard() skips the
|
||||
// native-tool safety net entirely — running wl-copy/xclip/pbcopy in
|
||||
// parallel with OSC 52 races the terminal's own clipboard write and can
|
||||
// corrupt it (e.g. wl-copy on Wayland holds the selection in a background
|
||||
// daemon; stacking two writes within ~30ms triggers a SIGTERM race).
|
||||
// Intentionally conservative: terminals with known flaky or disabled-by-
|
||||
// default OSC 52 (iTerm2 disables OSC 52 by default; Alacritty detection
|
||||
// is unreliable) are not on this list. Users on those terminals keep the
|
||||
// existing behaviour (native safety net fires alongside OSC 52).
|
||||
//
|
||||
// Lives here in utils/env.ts (rather than ink/terminal.ts) so that
|
||||
// ink/termio/osc.ts can import it without creating a circular dependency:
|
||||
// ink/terminal.ts already imports `link` from ink/termio/osc.ts.
|
||||
const OSC52_CAPABLE_TERMINALS = ['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode']
|
||||
|
||||
/** True if this terminal is known to correctly handle OSC 52 clipboard
|
||||
* writes, so setClipboard() can skip the native-tool safety net.
|
||||
* Accepts an optional terminal name for testability; defaults to the
|
||||
* module-level `env.terminal` detected at startup. */
|
||||
export function supportsOsc52Clipboard(terminal: string | null = env.terminal): boolean {
|
||||
return OSC52_CAPABLE_TERMINALS.includes(terminal ?? '')
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function isEnvTruthy(envVar: string | boolean | undefined): boolean {
|
||||
if (!envVar) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof envVar === 'boolean') {
|
||||
return envVar
|
||||
}
|
||||
|
||||
const v = envVar.toLowerCase().trim()
|
||||
|
||||
return ['1', 'true', 'yes', 'on'].includes(v)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { execFileNoThrow } from './execFileNoThrow.js'
|
||||
|
||||
// These tests shell out to /bin/sh, use chmodSync(0o755), and rely on
|
||||
// POSIX sleep/job control. They will not work on Windows.
|
||||
const onWindows = process.platform === 'win32'
|
||||
|
||||
// We simulate `wl-copy`'s daemonization behavior with a tiny shell script:
|
||||
// 1. Fork a short-lived background sleeper that inherits stdio (so the
|
||||
// parent process's pipes can never close).
|
||||
// 2. Record the sleeper PID to a file so afterEach can clean it up.
|
||||
// 3. Exit immediately with status 0.
|
||||
//
|
||||
// Without resolveOnExit, the await on `'close'` hangs until SIGTERM at
|
||||
// timeout — exactly the production wl-copy bug. With resolveOnExit, the
|
||||
// promise settles on `'exit'` regardless of the inherited pipes.
|
||||
|
||||
let scriptDir: string
|
||||
let daemonScript: string
|
||||
let sleeperPids: number[]
|
||||
|
||||
/** Read the PID file the daemon script writes, and track it for afterEach cleanup. */
|
||||
function trackSleeperPid(pidFile: string): void {
|
||||
try {
|
||||
const pid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10)
|
||||
|
||||
if (pid > 0) {
|
||||
sleeperPids.push(pid)
|
||||
}
|
||||
} catch {
|
||||
// PID file not written or unreadable — sleeper may have already exited.
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sleeperPids = []
|
||||
scriptDir = join(tmpdir(), `hermes-execfile-test-${process.pid}-${Date.now()}`)
|
||||
mkdirSync(scriptDir, { recursive: true })
|
||||
daemonScript = join(scriptDir, 'fake-daemonizer.sh')
|
||||
// Posix sh: the `sleep 3 &` child inherits stdin/stdout/stderr from the
|
||||
// shell, which inherited them from `spawn(stdio: 'pipe')`. The shell
|
||||
// exits but its child (the sleeper) keeps the pipes open. Mirrors how
|
||||
// wl-copy double-forks then exits while the daemon holds the selection.
|
||||
// The sleeper writes its PID to $1 so we can clean it up reliably.
|
||||
writeFileSync(daemonScript, '#!/bin/sh\nsleep 3 &\necho $! > "$1"\nexit 0\n')
|
||||
chmodSync(daemonScript, 0o755)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Kill orphaned sleepers so they don't accumulate across watch runs.
|
||||
for (const pid of sleeperPids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// Already exited — fine.
|
||||
}
|
||||
}
|
||||
|
||||
rmSync(scriptDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe.skipIf(onWindows)('execFileNoThrow with daemon-style children', () => {
|
||||
// Formerly a documented forever-hang: without resolveOnExit, the 'close'
|
||||
// event doesn't fire when the immediate child has exited but a forked
|
||||
// daemon still holds stdio open, and even the SIGTERM at timeout used to
|
||||
// leave the promise unsettled (#93134). The unconditional settle(124) in
|
||||
// the timeout handler now guarantees the await resolves with 124.
|
||||
// The daemon script's sleeper lives 30s so it genuinely outlives the
|
||||
// timeout (and vitest's own 5s test timeout — before the fix this test
|
||||
// fails by timing out, not by asserting).
|
||||
it('settles with code=124 on timeout when a daemon inherits stdio and resolveOnExit is off', async () => {
|
||||
const pidFile = join(scriptDir, 'sleeper-skip.pid')
|
||||
const longDaemonScript = join(scriptDir, 'fake-daemonizer-long.sh')
|
||||
writeFileSync(longDaemonScript, '#!/bin/sh\nsleep 30 &\necho $! > "$1"\nexit 0\n')
|
||||
chmodSync(longDaemonScript, 0o755)
|
||||
const start = Date.now()
|
||||
|
||||
const result = await execFileNoThrow(longDaemonScript, [pidFile], { timeout: 300 })
|
||||
trackSleeperPid(pidFile)
|
||||
|
||||
expect(result.code).toBe(124)
|
||||
expect(Date.now() - start).toBeLessThan(2000)
|
||||
})
|
||||
|
||||
it("settles immediately on 'exit' when resolveOnExit is true, regardless of daemon stdio", async () => {
|
||||
const pidFile = join(scriptDir, 'sleeper-exit.pid')
|
||||
const start = Date.now()
|
||||
|
||||
const result = await execFileNoThrow(daemonScript, [pidFile], {
|
||||
timeout: 2000,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
trackSleeperPid(pidFile)
|
||||
|
||||
const elapsed = Date.now() - start
|
||||
|
||||
// The shell exits in a few ms. resolveOnExit lets us return on exit
|
||||
// (code 0) instead of waiting for the orphaned sleeper to release
|
||||
// stdio. Should be well under 200ms even on slow CI.
|
||||
expect(result.code).toBe(0)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
})
|
||||
|
||||
it("still surfaces the right code when resolveOnExit'd child exits non-zero", async () => {
|
||||
const pidFile = join(scriptDir, 'sleeper-fail.pid')
|
||||
const failScript = join(scriptDir, 'fail.sh')
|
||||
writeFileSync(failScript, `#!/bin/sh\nsleep 3 &\necho $! > "${pidFile}"\nexit 7\n`)
|
||||
chmodSync(failScript, 0o755)
|
||||
|
||||
const result = await execFileNoThrow(failScript, [], {
|
||||
timeout: 2000,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
trackSleeperPid(pidFile)
|
||||
|
||||
expect(result.code).toBe(7)
|
||||
})
|
||||
|
||||
it('settles on timeout=124 when the child itself never exits, even with resolveOnExit', async () => {
|
||||
const slowScript = join(scriptDir, 'slow.sh')
|
||||
writeFileSync(slowScript, '#!/bin/sh\nsleep 30\n')
|
||||
chmodSync(slowScript, 0o755)
|
||||
|
||||
const result = await execFileNoThrow(slowScript, [], {
|
||||
timeout: 200,
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
// Child process never exits on its own → timer fires → SIGTERM →
|
||||
// child exits → 'exit' fires with non-null signal. The settle()
|
||||
// call from the timer registers code=124 first. Either way: 124.
|
||||
expect(result.code).toBe(124)
|
||||
})
|
||||
|
||||
it('does not double-resolve when both timer and exit fire', async () => {
|
||||
const pidFile = join(scriptDir, 'sleeper-race.pid')
|
||||
|
||||
// Race: child happens to exit right around the timeout. The settled
|
||||
// guard ensures only the first resolution wins.
|
||||
const result = await execFileNoThrow(daemonScript, [pidFile], {
|
||||
timeout: 50, // very tight
|
||||
resolveOnExit: true
|
||||
})
|
||||
|
||||
trackSleeperPid(pidFile)
|
||||
|
||||
// Either code=0 (exit beat timer) or code=124 (timer beat exit).
|
||||
// Both are valid outcomes; the contract is that the promise settles
|
||||
// exactly once and doesn't throw.
|
||||
expect([0, 124]).toContain(result.code)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { type ChildProcess, spawn, type StdioOptions } from 'child_process'
|
||||
type ExecFileOptions = {
|
||||
input?: string
|
||||
timeout?: number
|
||||
useCwd?: boolean
|
||||
env?: NodeJS.ProcessEnv
|
||||
/** Resolve as soon as the child *exits*, instead of waiting for its
|
||||
* stdio streams to close. Use this for tools that fork a daemon and
|
||||
* let the daemon inherit the parent's stdio (e.g. `wl-copy`): the
|
||||
* child exits immediately, but `'close'` never fires because the
|
||||
* daemon holds the pipes open.
|
||||
*
|
||||
* When true, stdout and stderr are set to 'ignore' to prevent the
|
||||
* daemon from inheriting those pipe FDs — the caller must not
|
||||
* depend on collecting stdout/stderr content. Both will always be
|
||||
* empty strings in this mode. */
|
||||
resolveOnExit?: boolean
|
||||
}
|
||||
|
||||
export function execFileNoThrow(
|
||||
file: string,
|
||||
args: string[],
|
||||
options: ExecFileOptions = {}
|
||||
): Promise<{
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number
|
||||
error?: string
|
||||
}> {
|
||||
return new Promise(resolve => {
|
||||
// When resolveOnExit is true, ignore stdout/stderr so the daemon
|
||||
// doesn't inherit those pipe FDs — prevents handle leaks that can
|
||||
// keep the parent process alive. No output data is collected in
|
||||
// this mode; both stdout and stderr will be empty strings.
|
||||
const stdioConfig: StdioOptions = options.resolveOnExit ? ['pipe', 'ignore', 'ignore'] : 'pipe'
|
||||
|
||||
const child: ChildProcess = spawn(file, args, {
|
||||
cwd: options.useCwd ? process.cwd() : undefined,
|
||||
env: options.env,
|
||||
stdio: stdioConfig
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let timedOut = false
|
||||
let settled = false
|
||||
|
||||
const settle = (code: number, error?: string) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Destroy any remaining streams to release FDs promptly.
|
||||
// After settle(), nobody reads from these anymore.
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
|
||||
resolve({ stdout, stderr, code, ...(error ? { error } : {}) })
|
||||
}
|
||||
|
||||
const timer = options.timeout
|
||||
? setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill('SIGTERM')
|
||||
|
||||
// Settle unconditionally: SIGTERM-ing the child does not
|
||||
// guarantee 'close'/'exit' will fire. In the default
|
||||
// (non-resolveOnExit) path a daemonized grandchild that
|
||||
// inherited the stdio pipes keeps them open forever, so
|
||||
// 'close' never fires and the promise leaked (#93134).
|
||||
// The settled-guard makes this a no-op when the child's own
|
||||
// 'exit'/'close' won the race.
|
||||
settle(124)
|
||||
}, options.timeout)
|
||||
: null
|
||||
|
||||
child.stdout?.on('data', chunk => {
|
||||
stdout += String(chunk)
|
||||
})
|
||||
child.stderr?.on('data', chunk => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
child.on('error', error => {
|
||||
settle(1, String(error))
|
||||
})
|
||||
|
||||
if (options.resolveOnExit) {
|
||||
// 'exit' fires when the child process itself exits — even if the
|
||||
// daemon it forked still holds the inherited stdio pipes open.
|
||||
// When a signal kills the child, code is null — map that to 1
|
||||
// so callers don't mistake a signal-terminated run for success.
|
||||
child.on('exit', (code, signal) => {
|
||||
const exitCode = timedOut ? 124 : (code ?? (signal ? 1 : 0))
|
||||
settle(exitCode)
|
||||
})
|
||||
} else {
|
||||
child.on('close', (code, signal) => {
|
||||
const exitCode = timedOut ? 124 : (code ?? (signal ? 1 : 0))
|
||||
settle(exitCode)
|
||||
})
|
||||
}
|
||||
|
||||
if (options.input) {
|
||||
child.stdin?.write(options.input)
|
||||
}
|
||||
|
||||
child.stdin?.end()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isMouseClicksDisabled(): boolean {
|
||||
return /^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE_CLICKS ?? '').trim().toLowerCase())
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
let graphemeSegmenter: Intl.Segmenter | null = null
|
||||
let wordSegmenter: Intl.Segmenter | null = null
|
||||
|
||||
export function getGraphemeSegmenter(): Intl.Segmenter {
|
||||
if (!graphemeSegmenter) {
|
||||
graphemeSegmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'grapheme'
|
||||
})
|
||||
}
|
||||
|
||||
return graphemeSegmenter
|
||||
}
|
||||
|
||||
export function firstGrapheme(text: string): string {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const segments = getGraphemeSegmenter().segment(text)
|
||||
const first = segments[Symbol.iterator]().next().value
|
||||
|
||||
return first?.segment ?? ''
|
||||
}
|
||||
|
||||
export function lastGrapheme(text: string): string {
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let last = ''
|
||||
|
||||
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
||||
last = segment
|
||||
}
|
||||
|
||||
return last
|
||||
}
|
||||
|
||||
export function getWordSegmenter(): Intl.Segmenter {
|
||||
if (!wordSegmenter) {
|
||||
wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' })
|
||||
}
|
||||
|
||||
return wordSegmenter
|
||||
}
|
||||
|
||||
const rtfCache = new Map<string, Intl.RelativeTimeFormat>()
|
||||
|
||||
export function getRelativeTimeFormat(
|
||||
style: 'long' | 'short' | 'narrow',
|
||||
numeric: 'always' | 'auto'
|
||||
): Intl.RelativeTimeFormat {
|
||||
const key = `${style}:${numeric}`
|
||||
let rtf = rtfCache.get(key)
|
||||
|
||||
if (!rtf) {
|
||||
rtf = new Intl.RelativeTimeFormat('en', { style, numeric })
|
||||
rtfCache.set(key, rtf)
|
||||
}
|
||||
|
||||
return rtf
|
||||
}
|
||||
|
||||
let cachedTimeZone: string | null = null
|
||||
|
||||
export function getTimeZone(): string {
|
||||
if (!cachedTimeZone) {
|
||||
cachedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
|
||||
return cachedTimeZone
|
||||
}
|
||||
|
||||
let cachedSystemLocaleLanguage: string | undefined | null = null
|
||||
|
||||
export function getSystemLocaleLanguage(): string | undefined {
|
||||
if (cachedSystemLocaleLanguage === null) {
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
cachedSystemLocaleLanguage = new Intl.Locale(locale).language
|
||||
} catch {
|
||||
cachedSystemLocaleLanguage = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return cachedSystemLocaleLanguage
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function logError(error: unknown): void {
|
||||
if (!process.env.HERMES_INK_DEBUG_ERRORS) {
|
||||
return
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
let _npmSemver: typeof import('semver') | undefined
|
||||
|
||||
function getNpmSemver(): typeof import('semver') {
|
||||
if (!_npmSemver) {
|
||||
_npmSemver = require('semver') as typeof import('semver')
|
||||
}
|
||||
|
||||
return _npmSemver
|
||||
}
|
||||
|
||||
export function gt(a: string, b: string): boolean {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.order(a, b) === 1
|
||||
}
|
||||
|
||||
return getNpmSemver().gt(a, b, { loose: true })
|
||||
}
|
||||
|
||||
export function gte(a: string, b: string): boolean {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.order(a, b) >= 0
|
||||
}
|
||||
|
||||
return getNpmSemver().gte(a, b, { loose: true })
|
||||
}
|
||||
|
||||
export function lt(a: string, b: string): boolean {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.order(a, b) === -1
|
||||
}
|
||||
|
||||
return getNpmSemver().lt(a, b, { loose: true })
|
||||
}
|
||||
|
||||
export function lte(a: string, b: string): boolean {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.order(a, b) <= 0
|
||||
}
|
||||
|
||||
return getNpmSemver().lte(a, b, { loose: true })
|
||||
}
|
||||
|
||||
export function satisfies(version: string, range: string): boolean {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.satisfies(version, range)
|
||||
}
|
||||
|
||||
return getNpmSemver().satisfies(version, range, { loose: true })
|
||||
}
|
||||
|
||||
export function order(a: string, b: string): -1 | 0 | 1 {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Bun.semver.order(a, b)
|
||||
}
|
||||
|
||||
return getNpmSemver().compare(a, b, { loose: true }) as -1 | 0 | 1
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
|
||||
import { lruEvict } from '../ink/lru.js'
|
||||
import { stringWidth } from '../ink/stringWidth.js'
|
||||
|
||||
function isEndCode(code: AnsiCode): boolean {
|
||||
return code.code === code.endCode
|
||||
}
|
||||
|
||||
function filterStartCodes(codes: AnsiCode[]): AnsiCode[] {
|
||||
return codes.filter(c => !isEndCode(c))
|
||||
}
|
||||
|
||||
// LRU cache: same (string, start, end) → same output. Output.get() re-emits
|
||||
// identical writes every frame for stable transcript content; this avoids
|
||||
// re-tokenizing them. CPU profile (Apr 2026) showed sliceAnsi at 18% total
|
||||
// time during scroll. Bounded at 4096 entries — entries are short clipped
|
||||
// lines so memory cost is small.
|
||||
const sliceCache = new Map<string, string>()
|
||||
const SLICE_CACHE_LIMIT = 4096
|
||||
|
||||
export default function sliceAnsi(str: string, start: number, end?: number): string {
|
||||
if (!str) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Hot-path: only cache when end is defined (the Output.get() use-case).
|
||||
if (end !== undefined) {
|
||||
const key = `${start}|${end}|${str}`
|
||||
const cached = sliceCache.get(key)
|
||||
|
||||
if (cached !== undefined) {
|
||||
sliceCache.delete(key)
|
||||
sliceCache.set(key, cached)
|
||||
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = computeSlice(str, start, end)
|
||||
|
||||
if (sliceCache.size >= SLICE_CACHE_LIMIT) {
|
||||
sliceCache.delete(sliceCache.keys().next().value!)
|
||||
}
|
||||
|
||||
sliceCache.set(key, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return computeSlice(str, start, end)
|
||||
}
|
||||
|
||||
export function sliceCacheSize(): number {
|
||||
return sliceCache.size
|
||||
}
|
||||
|
||||
export function evictSliceCache(keepRatio = 0): void {
|
||||
lruEvict(sliceCache, keepRatio)
|
||||
}
|
||||
|
||||
function computeSlice(str: string, start: number, end?: number): string {
|
||||
const tokens = tokenize(str)
|
||||
let activeCodes: AnsiCode[] = []
|
||||
let position = 0
|
||||
let result = ''
|
||||
let include = false
|
||||
|
||||
for (const token of tokens) {
|
||||
const width = token.type === 'ansi' ? 0 : token.fullWidth ? 2 : stringWidth(token.value)
|
||||
|
||||
if (end !== undefined && position >= end) {
|
||||
if (token.type === 'ansi' || width > 0 || !include) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (token.type === 'ansi') {
|
||||
activeCodes.push(token)
|
||||
|
||||
if (include) {
|
||||
result += token.code
|
||||
}
|
||||
} else {
|
||||
if (!include && position >= start) {
|
||||
if (start > 0 && width === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
include = true
|
||||
activeCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
|
||||
result = ansiCodesToString(activeCodes)
|
||||
}
|
||||
|
||||
if (include) {
|
||||
result += token.value
|
||||
}
|
||||
|
||||
position += width
|
||||
}
|
||||
}
|
||||
|
||||
const activeStartCodes = filterStartCodes(reduceAnsiCodes(activeCodes))
|
||||
result += ansiCodesToString(undoAnsiCodes(activeStartCodes))
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user