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:
Brooklyn Nicholson
2026-04-26 23:31:49 -05:00
parent e63929d4f3
commit b9393296fc
4 changed files with 194 additions and 17 deletions

View File

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

View 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';

View 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')
})
})
})

View 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';