diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 3b916d3d8d..abcd0f4663 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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); } - - if (!display && placeholder) { - return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) + else if (!display && placeholder) { + result = nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)); } - - if (selected) { - return renderWithSelection(display, selected.start, selected.end) + else if (selected) { + result = renderWithSelection(display, selected.start, selected.end); } - - return nativeCursor ? display || ' ' : renderWithCursor(display, cur) + else { + 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]) 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 } - 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} > diff --git a/ui-tui/src/lib/ansiSafeguard.ts b/ui-tui/src/lib/ansiSafeguard.ts new file mode 100644 index 0000000000..2b9987696a --- /dev/null +++ b/ui-tui/src/lib/ansiSafeguard.ts @@ -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'; \ No newline at end of file diff --git a/ui-tui/ui-tui/__tests__/lib/ansiSafeguard.test.ts b/ui-tui/ui-tui/__tests__/lib/ansiSafeguard.test.ts new file mode 100644 index 0000000000..5f35d1df0a --- /dev/null +++ b/ui-tui/ui-tui/__tests__/lib/ansiSafeguard.test.ts @@ -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') + }) + }) +}) \ No newline at end of file diff --git a/ui-tui/ui-tui/src/lib/ansiSafeguard.ts b/ui-tui/ui-tui/src/lib/ansiSafeguard.ts new file mode 100644 index 0000000000..2b9987696a --- /dev/null +++ b/ui-tui/ui-tui/src/lib/ansiSafeguard.ts @@ -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'; \ No newline at end of file