feat: fork ink and make it work nicely

This commit is contained in:
Brooklyn Nicholson 2026-04-11 11:29:08 -05:00
parent cb79018977
commit 8760faf991
139 changed files with 24952 additions and 140 deletions

View file

@ -0,0 +1,6 @@
export function logForDebugging(
_message: string,
_options: {
level?: string
} = {}
): void {}

View file

@ -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
}

View file

@ -0,0 +1,41 @@
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()
}

View file

@ -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)
}

View file

@ -0,0 +1,64 @@
import { spawn } from 'child_process'
type ExecFileOptions = {
input?: string
timeout?: number
useCwd?: boolean
env?: NodeJS.ProcessEnv
}
export function execFileNoThrow(
file: string,
args: string[],
options: ExecFileOptions = {}
): Promise<{
stdout: string
stderr: string
code: number
error?: string
}> {
return new Promise(resolve => {
const child = spawn(file, args, {
cwd: options.useCwd ? process.cwd() : undefined,
env: options.env,
stdio: 'pipe'
})
let stdout = ''
let stderr = ''
let timedOut = false
const timer = options.timeout
? setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
}, options.timeout)
: null
child.stdout?.on('data', chunk => {
stdout += String(chunk)
})
child.stderr?.on('data', chunk => {
stderr += String(chunk)
})
child.on('error', error => {
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code: 1, error: String(error) })
})
child.on('close', code => {
if (timer) {
clearTimeout(timer)
}
resolve({ stdout, stderr, code: timedOut ? 124 : (code ?? 0) })
})
if (options.input) {
child.stdin?.write(options.input)
}
child.stdin?.end()
})
}

View file

@ -0,0 +1,3 @@
export function isMouseClicksDisabled(): boolean {
return false
}

View file

@ -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
}

View file

@ -0,0 +1,7 @@
export function logError(error: unknown): void {
if (!process.env.HERMES_INK_DEBUG_ERRORS) {
return
}
console.error(error)
}

View file

@ -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 })
}

View file

@ -0,0 +1,58 @@
import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize'
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))
}
export default function sliceAnsi(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
}