mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Compare commits
1 Commits
feat/langf
...
bb/fix-tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9393296fc |
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||||
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||||
|
import { ensureSafeAnsi } from '../lib/ansiSafeguard.js'
|
||||||
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
|
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
|
||||||
|
|
||||||
type InkExt = typeof Ink & {
|
type InkExt = typeof Ink & {
|
||||||
@@ -229,6 +230,7 @@ function renderWithCursor(value: string, cursor: number) {
|
|||||||
|
|
||||||
for (const { segment, index } of seg().segment(value)) {
|
for (const { segment, index } of seg().segment(value)) {
|
||||||
if (!done && index >= pos) {
|
if (!done && index >= pos) {
|
||||||
|
// Add invert formatting with explicit reset to prevent ANSI leakage
|
||||||
out += invert(index === pos && segment !== '\n' ? segment : ' ')
|
out += invert(index === pos && segment !== '\n' ? segment : ' ')
|
||||||
done = true
|
done = true
|
||||||
|
|
||||||
@@ -240,7 +242,10 @@ function renderWithCursor(value: string, cursor: number) {
|
|||||||
out += segment
|
out += segment
|
||||||
}
|
}
|
||||||
|
|
||||||
return done ? out : out + invert(' ')
|
const result = done ? out : out + invert(' ')
|
||||||
|
|
||||||
|
// Ensure we always have a reset at the end to prevent ANSI leakage during scrolling
|
||||||
|
return result + (result.endsWith(INV_OFF) ? '' : INV_OFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWithSelection(value: string, start: number, end: number) {
|
function renderWithSelection(value: string, start: number, end: number) {
|
||||||
@@ -248,7 +253,11 @@ function renderWithSelection(value: string, start: number, end: number) {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
|
// Make sure we add explicit reset codes to prevent ANSI leakage
|
||||||
|
const result = value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
|
||||||
|
|
||||||
|
// Ensure we always have a reset at the end to prevent ANSI leakage during scrolling
|
||||||
|
return result + (result.endsWith(INV_OFF) ? '' : INV_OFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
function useFwdDelete(active: boolean) {
|
function useFwdDelete(active: boolean) {
|
||||||
@@ -339,19 +348,23 @@ export function TextInput({
|
|||||||
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
||||||
|
|
||||||
const rendered = useMemo(() => {
|
const rendered = useMemo(() => {
|
||||||
|
let result = '';
|
||||||
|
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
return display || dim(placeholder)
|
result = display || dim(placeholder);
|
||||||
}
|
}
|
||||||
|
else if (!display && placeholder) {
|
||||||
if (!display && placeholder) {
|
result = nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1));
|
||||||
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
|
|
||||||
}
|
}
|
||||||
|
else if (selected) {
|
||||||
if (selected) {
|
result = renderWithSelection(display, selected.start, selected.end);
|
||||||
return renderWithSelection(display, selected.start, selected.end)
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
|
result = nativeCursor ? display || ' ' : renderWithCursor(display, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety check to ensure no ANSI escape codes are leaked
|
||||||
|
return ensureSafeAnsi(result);
|
||||||
}, [cur, display, focus, nativeCursor, placeholder, selected])
|
}, [cur, display, focus, nativeCursor, placeholder, selected])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -888,19 +901,57 @@ export function TextInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSel()
|
if (selRef.current) {
|
||||||
|
// If there's an existing selection and user is clicking
|
||||||
|
// without modifier keys, clear it and move cursor
|
||||||
|
selRef.current = null
|
||||||
|
setSel(null)
|
||||||
|
}
|
||||||
|
|
||||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||||
setCur(next)
|
setCur(next)
|
||||||
curRef.current = next
|
curRef.current = next
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e: { button: number }) => {
|
onDrag={(e: { localRow?: number; localCol?: number }) => {
|
||||||
// Right-click to paste: route through the same hotkey path as
|
// Handle text selection during mouse drag
|
||||||
// Alt+V so the composer's clipboard RPC (text or image) handles it.
|
if (!focus) {
|
||||||
if (!focus || e.button !== 2) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
const currentPos = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||||
|
|
||||||
|
// Create or update selection
|
||||||
|
const selStart = curRef.current
|
||||||
|
const selEnd = currentPos
|
||||||
|
|
||||||
|
if (selStart !== selEnd) {
|
||||||
|
const nextSel = { start: selStart, end: selEnd }
|
||||||
|
selRef.current = nextSel
|
||||||
|
setSel(nextSel)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e: { button: number; localRow?: number; localCol?: number }) => {
|
||||||
|
// Right-click to paste: route through the same hotkey path as
|
||||||
|
// Alt+V so the composer's clipboard RPC (text or image) handles it.
|
||||||
|
if (!focus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.button === 2) {
|
||||||
|
// Right-click paste handling
|
||||||
|
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||||
|
} else if (e.button === 0) {
|
||||||
|
// Left-click: Start potential selection by setting the cursor position
|
||||||
|
const clickPos = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||||
|
setCur(clickPos)
|
||||||
|
curRef.current = clickPos
|
||||||
|
|
||||||
|
// Clear any existing selection
|
||||||
|
if (selRef.current) {
|
||||||
|
selRef.current = null
|
||||||
|
setSel(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
>
|
>
|
||||||
|
|||||||
47
ui-tui/src/lib/ansiSafeguard.ts
Normal file
47
ui-tui/src/lib/ansiSafeguard.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* ANSI sequence safety utilities for preventing escape code leaks
|
||||||
|
* during terminal rendering operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures all ANSI sequences are properly terminated
|
||||||
|
* This helps prevent escape code leaks during complex rendering operations
|
||||||
|
*/
|
||||||
|
export function ensureSafeAnsi(str: string): string {
|
||||||
|
// List of ANSI sequence starters we need to ensure are terminated
|
||||||
|
const sequences = [
|
||||||
|
{ start: '\x1b[7m', end: '\x1b[27m' }, // Invert
|
||||||
|
{ start: '\x1b[2m', end: '\x1b[22m' }, // Dim
|
||||||
|
{ start: '\x1b[1m', end: '\x1b[22m' }, // Bold
|
||||||
|
{ start: '\x1b[4m', end: '\x1b[24m' }, // Underline
|
||||||
|
{ start: '\x1b[5m', end: '\x1b[25m' }, // Blink
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check for any unterminated sequences and add their reset codes
|
||||||
|
let result = str;
|
||||||
|
|
||||||
|
for (const seq of sequences) {
|
||||||
|
// Count occurrences of start and end sequences
|
||||||
|
const startMatches = (result.match(new RegExp(seq.start.replace(/\[/g, '\\['), 'g')) || []).length;
|
||||||
|
const endMatches = (result.match(new RegExp(seq.end.replace(/\[/g, '\\['), 'g')) || []).length;
|
||||||
|
|
||||||
|
// If we have more starts than ends, add the missing end sequences
|
||||||
|
if (startMatches > endMatches) {
|
||||||
|
const missing = startMatches - endMatches;
|
||||||
|
result += seq.end.repeat(missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety - add a complete reset code at the end if there might be issues
|
||||||
|
const hasAnyEscapeSequence = /\x1b\[/.test(result);
|
||||||
|
if (hasAnyEscapeSequence) {
|
||||||
|
result += '\x1b[0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all ANSI attributes to default
|
||||||
|
*/
|
||||||
|
export const RESET_ALL = '\x1b[0m';
|
||||||
32
ui-tui/ui-tui/__tests__/lib/ansiSafeguard.test.ts
Normal file
32
ui-tui/ui-tui/__tests__/lib/ansiSafeguard.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ensureSafeAnsi } from '../../src/lib/ansiSafeguard'
|
||||||
|
|
||||||
|
describe('ansiSafeguard', () => {
|
||||||
|
describe('ensureSafeAnsi', () => {
|
||||||
|
it('should handle text without any ANSI codes', () => {
|
||||||
|
const input = 'Hello, world!'
|
||||||
|
expect(ensureSafeAnsi(input)).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should properly handle already terminated invert sequences', () => {
|
||||||
|
const input = '\x1b[7mInverted text\x1b[27m'
|
||||||
|
expect(ensureSafeAnsi(input)).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add missing invert termination', () => {
|
||||||
|
const input = '\x1b[7mInverted text'
|
||||||
|
expect(ensureSafeAnsi(input)).toBe('\x1b[7mInverted text\x1b[27m\x1b[0m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple unterminated sequences', () => {
|
||||||
|
const input = '\x1b[7mInverted \x1b[2mdim text'
|
||||||
|
const expected = '\x1b[7mInverted \x1b[2mdim text\x1b[22m\x1b[27m\x1b[0m'
|
||||||
|
expect(ensureSafeAnsi(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should add reset code to any string with ANSI sequences', () => {
|
||||||
|
const input = '\x1b[7mInverted text\x1b[27m'
|
||||||
|
expect(ensureSafeAnsi(input)).toBe('\x1b[7mInverted text\x1b[27m\x1b[0m')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
47
ui-tui/ui-tui/src/lib/ansiSafeguard.ts
Normal file
47
ui-tui/ui-tui/src/lib/ansiSafeguard.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* ANSI sequence safety utilities for preventing escape code leaks
|
||||||
|
* during terminal rendering operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures all ANSI sequences are properly terminated
|
||||||
|
* This helps prevent escape code leaks during complex rendering operations
|
||||||
|
*/
|
||||||
|
export function ensureSafeAnsi(str: string): string {
|
||||||
|
// List of ANSI sequence starters we need to ensure are terminated
|
||||||
|
const sequences = [
|
||||||
|
{ start: '\x1b[7m', end: '\x1b[27m' }, // Invert
|
||||||
|
{ start: '\x1b[2m', end: '\x1b[22m' }, // Dim
|
||||||
|
{ start: '\x1b[1m', end: '\x1b[22m' }, // Bold
|
||||||
|
{ start: '\x1b[4m', end: '\x1b[24m' }, // Underline
|
||||||
|
{ start: '\x1b[5m', end: '\x1b[25m' }, // Blink
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check for any unterminated sequences and add their reset codes
|
||||||
|
let result = str;
|
||||||
|
|
||||||
|
for (const seq of sequences) {
|
||||||
|
// Count occurrences of start and end sequences
|
||||||
|
const startMatches = (result.match(new RegExp(seq.start.replace(/\[/g, '\\['), 'g')) || []).length;
|
||||||
|
const endMatches = (result.match(new RegExp(seq.end.replace(/\[/g, '\\['), 'g')) || []).length;
|
||||||
|
|
||||||
|
// If we have more starts than ends, add the missing end sequences
|
||||||
|
if (startMatches > endMatches) {
|
||||||
|
const missing = startMatches - endMatches;
|
||||||
|
result += seq.end.repeat(missing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final safety - add a complete reset code at the end if there might be issues
|
||||||
|
const hasAnyEscapeSequence = /\x1b\[/.test(result);
|
||||||
|
if (hasAnyEscapeSequence) {
|
||||||
|
result += '\x1b[0m';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all ANSI attributes to default
|
||||||
|
*/
|
||||||
|
export const RESET_ALL = '\x1b[0m';
|
||||||
Reference in New Issue
Block a user