fix: lineEditor のサロゲートペア対応と Ctrl+J 改行挿入を追加

This commit is contained in:
nrslib 2026-02-24 23:51:07 +09:00
parent f9c30be093
commit 6bea78adb4
2 changed files with 134 additions and 7 deletions

View File

@ -120,6 +120,26 @@ describe('parseInputData', () => {
expect(cb.calls).not.toContain('char:f');
});
});
describe('Ctrl+J key detection', () => {
it('should emit char event for Ctrl+J', () => {
// Given
const cb = createCallbacks();
// When
parseInputData('\x0A', cb);
// Then
expect(cb.calls).toEqual(['char:\n']);
});
it('should emit char event for Ctrl+J mixed with regular chars', () => {
// Given
const cb = createCallbacks();
// When
parseInputData('a\x0Ab', cb);
// Then
expect(cb.calls).toEqual(['char:a', 'char:\n', 'char:b']);
});
});
});
describe('readMultilineInput cursor navigation', () => {
@ -958,4 +978,85 @@ describe('readMultilineInput cursor navigation', () => {
expect(result).toBe('abcdefghijklmnopqrstuXvwx\n123');
});
});
describe('surrogate pair (emoji) support', () => {
it('should move left past emoji', async () => {
// Given
setupRawStdin(['🎵\x1B[DX\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('X🎵');
});
it('should move right through emoji', async () => {
// Given
setupRawStdin(['🎵\x1B[H\x1B[CX\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('🎵X');
});
it('should backspace emoji completely', async () => {
// Given
setupRawStdin(['🎵\x7F\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('');
});
it('should not leave broken surrogate pair after backspace', async () => {
// Given
setupRawStdin(['a🎵b\x7FX\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('a🎵X');
});
it('should handle multiple emojis with arrow navigation', async () => {
// Given
setupRawStdin(['😀🎵\x1B[D\x1B[DX\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('X😀🎵');
});
});
describe('Ctrl+J inserts newline', () => {
it('should insert newline with Ctrl+J at end of line', async () => {
// Given
setupRawStdin(['abc\x0Adef\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abc\ndef');
});
it('should insert newline with Ctrl+J mid-line', async () => {
// Given
setupRawStdin(['abcdef\x1B[H\x1B[C\x1B[C\x1B[C\x0A\r']);
// When
const result = await callReadMultilineInput('> ');
// Then
expect(result).toBe('abc\ndef');
});
});
});

View File

@ -250,6 +250,28 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
// --- Buffer position helpers ---
/** Get the JS string length of the character at buffer position `pos` */
function charLengthAt(pos: number): number {
if (pos >= buffer.length) return 0;
const code = buffer.charCodeAt(pos);
if (code >= 0xD800 && code <= 0xDBFF && pos + 1 < buffer.length) {
const next = buffer.charCodeAt(pos + 1);
if (next >= 0xDC00 && next <= 0xDFFF) return 2;
}
return 1;
}
/** Get the JS string length of the character immediately before buffer position `pos` */
function charLengthBefore(pos: number): number {
if (pos === 0) return 0;
const code = buffer.charCodeAt(pos - 1);
if (code >= 0xDC00 && code <= 0xDFFF && pos >= 2) {
const prev = buffer.charCodeAt(pos - 2);
if (prev >= 0xD800 && prev <= 0xDBFF) return 2;
}
return 1;
}
function getLineStartAt(pos: number): number {
const lastNl = buffer.lastIndexOf('\n', pos - 1);
return lastNl + 1;
@ -475,9 +497,10 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
function deleteCharBefore(): void {
if (cursorPos <= getLineStart()) return;
const charWidth = getDisplayWidth(buffer[cursorPos - 1]!);
deleteRange(cursorPos - 1, cursorPos);
cursorPos--;
const len = charLengthBefore(cursorPos);
const charWidth = getDisplayWidth(buffer.slice(cursorPos - len, cursorPos));
deleteRange(cursorPos - len, cursorPos);
cursorPos -= len;
process.stdout.write(`\x1B[${charWidth}D`);
rerenderFromCursor();
}
@ -547,8 +570,9 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
onArrowLeft() {
if (state !== 'normal') return;
if (cursorPos > getLineStart()) {
const charWidth = getDisplayWidth(buffer[cursorPos - 1]!);
cursorPos--;
const len = charLengthBefore(cursorPos);
const charWidth = getDisplayWidth(buffer.slice(cursorPos - len, cursorPos));
cursorPos -= len;
process.stdout.write(`\x1B[${charWidth}D`);
} else if (getLineStart() > 0) {
cursorPos = getLineStart() - 1;
@ -560,8 +584,9 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
onArrowRight() {
if (state !== 'normal') return;
if (cursorPos < getLineEnd()) {
const charWidth = getDisplayWidth(buffer[cursorPos]!);
cursorPos++;
const len = charLengthAt(cursorPos);
const charWidth = getDisplayWidth(buffer.slice(cursorPos, cursorPos + len));
cursorPos += len;
process.stdout.write(`\x1B[${charWidth}C`);
} else if (cursorPos < buffer.length && buffer[cursorPos] === '\n') {
cursorPos++;
@ -672,6 +697,7 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
if (ch === '\x0B') { deleteToLineEnd(); return; }
if (ch === '\x15') { deleteToLineStart(); return; }
if (ch === '\x17') { deleteWord(); return; }
if (ch === '\x0A') { insertNewline(); return; }
// Ignore unknown control characters
if (ch.charCodeAt(0) < 0x20) return;
// Regular character