Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
b9393296fc 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.
2026-04-26 23:31:49 -05:00
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 { 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) {
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) { // Final safety check to ensure no ANSI escape codes are leaked
return nativeCursor ? dim(placeholder) : invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) return ensureSafeAnsi(result);
}
if (selected) {
return renderWithSelection(display, selected.start, selected.end)
}
return nativeCursor ? display || ' ' : renderWithCursor(display, cur)
}, [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}
> >

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