mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-15 04:12:25 +00:00
feat: fork ink and make it work nicely
This commit is contained in:
parent
cb79018977
commit
8760faf991
139 changed files with 24952 additions and 140 deletions
6
ui-tui/packages/hermes-ink/src/utils/debug.ts
Normal file
6
ui-tui/packages/hermes-ink/src/utils/debug.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function logForDebugging(
|
||||
_message: string,
|
||||
_options: {
|
||||
level?: string
|
||||
} = {}
|
||||
): void {}
|
||||
131
ui-tui/packages/hermes-ink/src/utils/earlyInput.ts
Normal file
131
ui-tui/packages/hermes-ink/src/utils/earlyInput.ts
Normal 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
|
||||
}
|
||||
41
ui-tui/packages/hermes-ink/src/utils/env.ts
Normal file
41
ui-tui/packages/hermes-ink/src/utils/env.ts
Normal 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()
|
||||
}
|
||||
13
ui-tui/packages/hermes-ink/src/utils/envUtils.ts
Normal file
13
ui-tui/packages/hermes-ink/src/utils/envUtils.ts
Normal 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)
|
||||
}
|
||||
64
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
Normal file
64
ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
3
ui-tui/packages/hermes-ink/src/utils/fullscreen.ts
Normal file
3
ui-tui/packages/hermes-ink/src/utils/fullscreen.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isMouseClicksDisabled(): boolean {
|
||||
return false
|
||||
}
|
||||
87
ui-tui/packages/hermes-ink/src/utils/intl.ts
Normal file
87
ui-tui/packages/hermes-ink/src/utils/intl.ts
Normal 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
|
||||
}
|
||||
7
ui-tui/packages/hermes-ink/src/utils/log.ts
Normal file
7
ui-tui/packages/hermes-ink/src/utils/log.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function logError(error: unknown): void {
|
||||
if (!process.env.HERMES_INK_DEBUG_ERRORS) {
|
||||
return
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
}
|
||||
57
ui-tui/packages/hermes-ink/src/utils/semver.ts
Normal file
57
ui-tui/packages/hermes-ink/src/utils/semver.ts
Normal 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 })
|
||||
}
|
||||
58
ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts
Normal file
58
ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue