From 6bea78adb4815da4c73c25fe0cd1ee31377b7d09 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:51:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20lineEditor=20=E3=81=AE=E3=82=B5=E3=83=AD?= =?UTF-8?q?=E3=82=B2=E3=83=BC=E3=83=88=E3=83=9A=E3=82=A2=E5=AF=BE=E5=BF=9C?= =?UTF-8?q?=E3=81=A8=20Ctrl+J=20=E6=94=B9=E8=A1=8C=E6=8C=BF=E5=85=A5?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/lineEditor.test.ts | 101 +++++++++++++++++++++++++ src/features/interactive/lineEditor.ts | 40 ++++++++-- 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/__tests__/lineEditor.test.ts b/src/__tests__/lineEditor.test.ts index ac7459c..eddfd64 100644 --- a/src/__tests__/lineEditor.test.ts +++ b/src/__tests__/lineEditor.test.ts @@ -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'); + }); + }); }); diff --git a/src/features/interactive/lineEditor.ts b/src/features/interactive/lineEditor.ts index 2d52355..19a723f 100644 --- a/src/features/interactive/lineEditor.ts +++ b/src/features/interactive/lineEditor.ts @@ -250,6 +250,28 @@ export function readMultilineInput(prompt: string): Promise { // --- 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 { 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 { 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 { 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 { 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