fix(tui): restore backspace behavior for raw DEL terminals (#182)
Ink reports the common DEL byte as a delete key, which means React TUI prompt editing can lose Backspace behavior on terminals that send 0x7f for backward delete. This change records the raw input sequence so PromptInput can treat raw DEL as backward delete while preserving true forward-delete escape sequences, and it adds a focused regression test plus changelog entry.\n\nConstraint: Ink normalizes raw DEL into key.delete for some terminal environments\nRejected: Treat delete at end-of-line as backspace | breaks true forward delete semantics on terminals that send distinct delete sequences\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep raw-sequence disambiguation aligned with Ink input semantics before refactoring PromptInput keyboard handling\nTested: /Users/wangxin/.openharness-venv/bin/ruff check src tests scripts\nTested: /Users/wangxin/.openharness-venv/bin/pytest -q\nTested: cd frontend/terminal && npx tsc --noEmit\nTested: cd frontend/terminal && node --import tsx --test src/components/MarkdownText.test.tsx src/components/PromptInput.test.tsx\nNot-tested: Manual verification on non-macOS terminals\nRelated: d4eae15
Co-authored-by: wangxin <wangxinwxwx@shopee.com>
This commit is contained in:
@@ -25,6 +25,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
|
||||
### Fixed
|
||||
|
||||
- React TUI prompt input now treats the raw DEL byte (`0x7f`) as backward delete while preserving true forward-delete escape sequences, fixing backspace failures seen in some macOS terminal environments.
|
||||
- `todo_write` tool now updates an existing unchecked item in-place when `checked=True` instead of appending a duplicate `[x]` line.
|
||||
|
||||
- Built-in `Explore` and `claude-code-guide` agents no longer hard-code `model="haiku"`, which caused them to fail for users on non-Anthropic providers (OpenAI, Bedrock, custom base URLs, etc.). Both agents now use `model="inherit"` so they run with whatever model the parent session is using. `build_inherited_cli_flags` is also fixed to skip the `--model` flag entirely when the value is `"inherit"`, letting the subprocess correctly inherit the parent model via the `OPENHARNESS_MODEL` environment variable instead of receiving the literal string `"inherit"` as a model name.
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import React, {useState} from 'react';
|
||||
import {render} from 'ink';
|
||||
|
||||
import {ThemeProvider} from '../theme/ThemeContext.js';
|
||||
import {PromptInput} from './PromptInput.js';
|
||||
|
||||
const nextLoopTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
type InkTestStdout = PassThrough & {
|
||||
isTTY: boolean;
|
||||
columns: number;
|
||||
rows: number;
|
||||
cursorTo: () => boolean;
|
||||
clearLine: () => boolean;
|
||||
moveCursor: () => boolean;
|
||||
};
|
||||
|
||||
type InkTestStdin = PassThrough & {
|
||||
isTTY: boolean;
|
||||
setRawMode: (_mode: boolean) => void;
|
||||
resume: () => InkTestStdin;
|
||||
pause: () => InkTestStdin;
|
||||
ref: () => InkTestStdin;
|
||||
unref: () => InkTestStdin;
|
||||
};
|
||||
|
||||
function createTestStdout(): InkTestStdout {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
columns: 120,
|
||||
rows: 40,
|
||||
cursorTo: () => true,
|
||||
clearLine: () => true,
|
||||
moveCursor: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestStdin(): InkTestStdin {
|
||||
return Object.assign(new PassThrough(), {
|
||||
isTTY: true,
|
||||
setRawMode: () => undefined,
|
||||
resume() {
|
||||
return this;
|
||||
},
|
||||
pause() {
|
||||
return this;
|
||||
},
|
||||
ref() {
|
||||
return this;
|
||||
},
|
||||
unref() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sendKey(stdin: InkTestStdin, chunk: string | Buffer): Promise<void> {
|
||||
stdin.write(chunk);
|
||||
await nextLoopTurn();
|
||||
await nextLoopTurn();
|
||||
}
|
||||
|
||||
async function waitForValue(getValue: () => string, expected: string): Promise<void> {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
await nextLoopTurn();
|
||||
if (getValue() === expected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(getValue(), expected);
|
||||
}
|
||||
|
||||
function PromptHarness({onInputChange}: {onInputChange: (value: string) => void}): React.JSX.Element {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
return (
|
||||
<ThemeProvider initialTheme="default">
|
||||
<PromptInput
|
||||
busy={false}
|
||||
input={input}
|
||||
setInput={(value) => {
|
||||
onInputChange(value);
|
||||
setInput(value);
|
||||
}}
|
||||
onSubmit={() => undefined}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
test('treats terminal DEL at end-of-line as backward delete', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
|
||||
const instance = render(<PromptHarness onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, 'a');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
|
||||
await sendKey(stdin, 'b');
|
||||
await waitForValue(() => currentValue, 'ab');
|
||||
|
||||
await sendKey(stdin, Buffer.from([0x7f]));
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps forward delete behavior when cursor is inside the line', async () => {
|
||||
const stdin = createTestStdin();
|
||||
const stdout = createTestStdout();
|
||||
let currentValue = '';
|
||||
|
||||
const instance = render(<PromptHarness onInputChange={(value) => {
|
||||
currentValue = value;
|
||||
}} />, {
|
||||
stdin: stdin as unknown as NodeJS.ReadStream & {fd: 0},
|
||||
stdout: stdout as unknown as NodeJS.WriteStream,
|
||||
debug: true,
|
||||
patchConsole: false,
|
||||
});
|
||||
const exitPromise = instance.waitUntilExit();
|
||||
|
||||
try {
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, 'a');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
|
||||
await sendKey(stdin, 'b');
|
||||
await waitForValue(() => currentValue, 'ab');
|
||||
|
||||
await sendKey(stdin, '\u001B[D');
|
||||
await nextLoopTurn();
|
||||
|
||||
await sendKey(stdin, '\u001B[3~');
|
||||
await waitForValue(() => currentValue, 'a');
|
||||
} finally {
|
||||
instance.unmount();
|
||||
await exitPromise;
|
||||
instance.cleanup();
|
||||
stdin.destroy();
|
||||
stdout.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Box, Text, useInput} from 'ink';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Box, Text, useInput, useStdin} from 'ink';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {useTheme} from '../theme/ThemeContext.js';
|
||||
@@ -23,11 +23,28 @@ function MultilineTextInput({
|
||||
promptColor: string;
|
||||
}): React.JSX.Element {
|
||||
const [cursorOffset, setCursorOffset] = useState(value.length);
|
||||
const {internal_eventEmitter} = useStdin();
|
||||
const lastSequenceRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
setCursorOffset((previous) => Math.min(previous, value.length));
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRawInput = (chunk: string | Buffer): void => {
|
||||
lastSequenceRef.current = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
||||
};
|
||||
|
||||
internal_eventEmitter.on('input', handleRawInput);
|
||||
return () => {
|
||||
internal_eventEmitter.removeListener('input', handleRawInput);
|
||||
};
|
||||
}, [focus, internal_eventEmitter]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!focus) {
|
||||
@@ -70,6 +87,19 @@ function MultilineTextInput({
|
||||
}
|
||||
|
||||
if (key.delete) {
|
||||
// Ink reports the common DEL byte (`0x7f`) as `delete`, even though
|
||||
// many terminals emit it for the Backspace key. Use the raw sequence
|
||||
// to distinguish that case from a true forward-delete escape sequence.
|
||||
if (lastSequenceRef.current === '\x7f' || lastSequenceRef.current === '\x1b\x7f') {
|
||||
if (cursorOffset === 0) {
|
||||
return;
|
||||
}
|
||||
const nextValue = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset);
|
||||
setCursorOffset(cursorOffset - 1);
|
||||
onChange(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursorOffset >= value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user