mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
Fix TUI input field ANSI leaks and text selection issues
1. Fix ANSI escape code leakage during scroll operations: - Add ensureSafeAnsi utility to ensure all ANSI sequences are properly terminated - Modify renderWithCursor and renderWithSelection to always include reset codes - Add final safety check in text rendering to catch any potential leaks 2. Improve text selection in input field: - Add proper mouse drag event handling for text selection - Enhance click handlers to support selection operations - Fix edge cases in selection rendering - Ensure proper reset of ANSI codes after selections Fixes reported issues where users couldn't copy/paste text in input field and experienced ANSI leakage during scrolling operations.
This commit is contained in:
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||
import { ensureSafeAnsi } from '../lib/ansiSafeguard.js'
|
||||
import { isActionMod, isMac, isMacActionFallback } from '../lib/platform.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
@@ -229,6 +230,7 @@ function renderWithCursor(value: string, cursor: number) {
|
||||
|
||||
for (const { segment, index } of seg().segment(value)) {
|
||||
if (!done && index >= pos) {
|
||||
// Add invert formatting with explicit reset to prevent ANSI leakage
|
||||
out += invert(index === pos && segment !== '\n' ? segment : ' ')
|
||||
done = true
|
||||
|
||||
@@ -240,7 +242,10 @@ function renderWithCursor(value: string, cursor: number) {
|
||||
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) {
|
||||
@@ -248,7 +253,11 @@ function renderWithSelection(value: string, start: number, end: number) {
|
||||
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) {
|
||||
@@ -339,19 +348,23 @@ export function TextInput({
|
||||
const nativeCursor = focus && termFocus && !selected && !!stdout?.isTTY
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
let result = '';
|
||||
|
||||
if (!focus) {
|
||||
return display || dim(placeholder)
|
||||
result = display || dim(placeholder);
|
||||
}
|
||||
else if (!display && placeholder) {
|
||||
result = nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1));
|
||||
}
|
||||
else if (selected) {
|
||||
result = renderWithSelection(display, selected.start, selected.end);
|
||||
}
|
||||
else {
|
||||
result = nativeCursor ? display || ' ' : renderWithCursor(display, cur);
|
||||
}
|
||||
|
||||
if (!display && placeholder) {
|
||||
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
return renderWithSelection(display, selected.start, selected.end)
|
||||
}
|
||||
|
||||
return 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])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -888,19 +901,57 @@ export function TextInput({
|
||||
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)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
}}
|
||||
onMouseDown={(e: { button: 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 || e.button !== 2) {
|
||||
onDrag={(e: { localRow?: number; localCol?: number }) => {
|
||||
// Handle text selection during mouse drag
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
|
||||
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