From 71cc3d8874885755d47df8d756dd256b5823ad94 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:05:37 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=B3=E7=B7=A8=E9=9B=86?= =?UTF-8?q?=E3=82=92=E5=88=86=E9=9B=A2=E3=81=97=E3=80=81=E3=82=AB=E3=83=BC?= =?UTF-8?q?=E3=82=BD=E3=83=AB=E7=AE=A1=E7=90=86=E3=83=BB=E8=A1=8C=E9=96=93?= =?UTF-8?q?=E7=A7=BB=E5=8B=95=E3=83=BBOption+Arrow=E5=8D=98=E8=AA=9E?= =?UTF-8?q?=E7=A7=BB=E5=8B=95=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interactive.ts から入力処理を lineEditor.ts に抽出。矢印キーによるカーソル移動、 行頭/行末折り返し、上下キーによる行間移動、全角文字の表示幅対応、 Option+Arrow(ESC b/f, CSI 1;3D/C)による単語単位移動を追加。 --- src/__tests__/interactive.test.ts | 306 +++++++++++- src/__tests__/lineEditor.test.ts | 614 ++++++++++++++++++++++++ src/features/interactive/interactive.ts | 247 +--------- src/features/interactive/lineEditor.ts | 561 ++++++++++++++++++++++ 4 files changed, 1478 insertions(+), 250 deletions(-) create mode 100644 src/__tests__/lineEditor.test.ts create mode 100644 src/features/interactive/lineEditor.ts diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 61a8593..d531042 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -608,10 +608,11 @@ describe('interactiveMode', () => { expect(result.action).toBe('cancel'); }); - it('should ignore arrow keys in normal mode', async () => { - // Given: text with arrow keys interspersed (arrows are ignored) + it('should move cursor with arrow keys and insert at position', async () => { + // Given: type "hllo", left 3 → cursor at 1, type "e", Enter + // buffer: "h" + "e" + "llo" = "hello" setupRawStdin([ - 'he\x1B[Dllo\x1B[C\r', + 'hllo\x1B[D\x1B[D\x1B[De\r', '/cancel\r', ]); setupMockProvider(['response']); @@ -619,7 +620,7 @@ describe('interactiveMode', () => { // When const result = await interactiveMode('/project'); - // Then: arrows are ignored, text is "hello" + // Then: arrow keys move cursor, "e" inserted at position 1 → "hello" const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; const prompt = mockProvider._call.mock.calls[0]?.[0] as string; expect(prompt).toContain('hello'); @@ -637,5 +638,302 @@ describe('interactiveMode', () => { // Then: empty input is skipped, falls through to /cancel expect(result.action).toBe('cancel'); }); + + it('should handle Ctrl+U to clear current line', async () => { + // Given: type "hello", Ctrl+U (\x15), type "world", Enter + setupRawStdin([ + 'hello\x15world\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + // When + const result = await interactiveMode('/project'); + + // Then: "hello" was cleared by Ctrl+U, only "world" remains + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('world'); + expect(prompt).not.toContain('helloworld'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+W to delete previous word', async () => { + // Given: type "hello world", Ctrl+W (\x17), Enter + setupRawStdin([ + 'hello world\x17\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + // When + const result = await interactiveMode('/project'); + + // Then: "world" was deleted by Ctrl+W, "hello " remains + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello'); + expect(prompt).not.toContain('world'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+H (backspace alternative) to delete character', async () => { + // Given: type "ab", Ctrl+H (\x08), type "c", Enter + setupRawStdin([ + 'ab\x08c\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + // When + const result = await interactiveMode('/project'); + + // Then: Ctrl+H deletes 'b', buffer is "ac" + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('ac'); + expect(result.action).toBe('cancel'); + }); + + it('should ignore unknown control characters (e.g. Ctrl+G)', async () => { + // Given: type "ab", Ctrl+G (\x07, bell), type "c", Enter + setupRawStdin([ + 'ab\x07c\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + // When + const result = await interactiveMode('/project'); + + // Then: Ctrl+G is ignored, buffer is "abc" + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('abc'); + expect(result.action).toBe('cancel'); + }); + }); + + describe('cursor management', () => { + it('should move cursor left with arrow key and insert at position', async () => { + // Given: type "helo", left 2, type "l", Enter → "hello" wait... + // "helo" cursor at 4, left 2 → cursor at 2, type "l" → insert at 2: "helelo"? No. + // Actually: "helo"[0]='h',[1]='e',[2]='l',[3]='o' + // cursor at 4, left 2 → cursor at 2 (before 'l'), type 'l' → "hel" + "l" + "o" = "hello"? No. + // Insert at index 2: "he" + "l" + "lo" = "hello". Yes! + setupRawStdin([ + 'helo\x1B[D\x1B[Dl\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + // When + const result = await interactiveMode('/project'); + + // Then: buffer should be "hello" + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello'); + expect(result.action).toBe('cancel'); + }); + + it('should move cursor right with arrow key after moving left', async () => { + // "hello" left 3 → cursor at 2, right 1 → cursor at 3, type "X" → "helXlo" + setupRawStdin([ + 'hello\x1B[D\x1B[D\x1B[D\x1B[CX\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('helXlo'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+A to move cursor to beginning of line', async () => { + // Type "world", Ctrl+A, type "hello ", Enter → "hello world" + setupRawStdin([ + 'world\x01hello \r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello world'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+A via Kitty CSI-u to move cursor to beginning', async () => { + // Type "test", Ctrl+A via Kitty ([97;5u), type "X", Enter → "Xtest" + setupRawStdin([ + 'test\x1B[97;5uX\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('Xtest'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+E to move cursor to end of line', async () => { + // Type "hello", Ctrl+A, Ctrl+E, type "!", Enter → "hello!" + setupRawStdin([ + 'hello\x01\x05!\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello!'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+K to delete from cursor to end of line', async () => { + // Type "hello world", left 6 (cursor before "world"), Ctrl+K, Enter → "hello" + // Actually: "hello world" length=11, left 6 → cursor at 5 (space before "world") + // Ctrl+K deletes from 5 to 11 → " world" removed → buffer "hello" + setupRawStdin([ + 'hello world\x1B[D\x1B[D\x1B[D\x1B[D\x1B[D\x1B[D\x0B\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello'); + expect(prompt).not.toContain('hello world'); + expect(result.action).toBe('cancel'); + }); + + it('should handle backspace in middle of text', async () => { + // Type "helllo", left 2, backspace, Enter + // "helllo" cursor at 6, left 2 → cursor at 4, backspace deletes [3]='l' → "hello" + setupRawStdin([ + 'helllo\x1B[D\x1B[D\x7F\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Home key to move to beginning of line', async () => { + // Type "world", Home (\x1B[H), type "hello ", Enter → "hello world" + setupRawStdin([ + 'world\x1B[Hhello \r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello world'); + expect(result.action).toBe('cancel'); + }); + + it('should handle End key to move to end of line', async () => { + // Type "hello", Home, End (\x1B[F), type "!", Enter → "hello!" + setupRawStdin([ + 'hello\x1B[H\x1B[F!\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello!'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+W with cursor in middle of text', async () => { + // Type "hello world!", left 1 (before !), Ctrl+W, Enter + // cursor at 11, Ctrl+W deletes "world" → "hello !" + setupRawStdin([ + 'hello world!\x1B[D\x17\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('hello !'); + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+U with cursor in middle of text', async () => { + // Type "hello world", left 5 (cursor at 6, before "world"), Ctrl+U, Enter + // Ctrl+U deletes "hello " → buffer becomes "world" + setupRawStdin([ + 'hello world\x1B[D\x1B[D\x1B[D\x1B[D\x1B[D\x15\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('world'); + expect(prompt).not.toContain('hello'); + expect(result.action).toBe('cancel'); + }); + + it('should not move cursor past line boundaries with arrow keys', async () => { + // Type "ab", left 3 (should stop at 0), type "X", Enter → "Xab" + setupRawStdin([ + 'ab\x1B[D\x1B[D\x1B[DX\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('Xab'); + expect(result.action).toBe('cancel'); + }); + + it('should not move cursor past line end with right arrow', async () => { + // Type "ab", right 2 (already at end, no effect), type "c", Enter → "abc" + setupRawStdin([ + 'ab\x1B[C\x1B[Cc\r', + '/cancel\r', + ]); + setupMockProvider(['response']); + + const result = await interactiveMode('/project'); + + const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType }; + const prompt = mockProvider._call.mock.calls[0]?.[0] as string; + expect(prompt).toContain('abc'); + expect(result.action).toBe('cancel'); + }); }); }); diff --git a/src/__tests__/lineEditor.test.ts b/src/__tests__/lineEditor.test.ts new file mode 100644 index 0000000..d081a50 --- /dev/null +++ b/src/__tests__/lineEditor.test.ts @@ -0,0 +1,614 @@ +/** + * Tests for lineEditor: parseInputData and readMultilineInput cursor navigation + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseInputData, type InputCallbacks } from '../features/interactive/lineEditor.js'; + +function createCallbacks(): InputCallbacks & { calls: string[] } { + const calls: string[] = []; + return { + calls, + onPasteStart() { calls.push('pasteStart'); }, + onPasteEnd() { calls.push('pasteEnd'); }, + onShiftEnter() { calls.push('shiftEnter'); }, + onArrowLeft() { calls.push('left'); }, + onArrowRight() { calls.push('right'); }, + onArrowUp() { calls.push('up'); }, + onArrowDown() { calls.push('down'); }, + onWordLeft() { calls.push('wordLeft'); }, + onWordRight() { calls.push('wordRight'); }, + onHome() { calls.push('home'); }, + onEnd() { calls.push('end'); }, + onChar(ch: string) { calls.push(`char:${ch}`); }, + }; +} + +describe('parseInputData', () => { + describe('arrow key detection', () => { + it('should detect arrow up escape sequence', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[A', cb); + // Then + expect(cb.calls).toEqual(['up']); + }); + + it('should detect arrow down escape sequence', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[B', cb); + // Then + expect(cb.calls).toEqual(['down']); + }); + + it('should detect arrow left escape sequence', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[D', cb); + // Then + expect(cb.calls).toEqual(['left']); + }); + + it('should detect arrow right escape sequence', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[C', cb); + // Then + expect(cb.calls).toEqual(['right']); + }); + + it('should parse mixed arrows and characters', () => { + // Given + const cb = createCallbacks(); + // When: type "a", up, "b", down + parseInputData('a\x1B[Ab\x1B[B', cb); + // Then + expect(cb.calls).toEqual(['char:a', 'up', 'char:b', 'down']); + }); + }); + + describe('option+arrow key detection', () => { + it('should detect ESC b as word left (Terminal.app style)', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1Bb', cb); + // Then + expect(cb.calls).toEqual(['wordLeft']); + }); + + it('should detect ESC f as word right (Terminal.app style)', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1Bf', cb); + // Then + expect(cb.calls).toEqual(['wordRight']); + }); + + it('should detect CSI 1;3D as word left (iTerm2/Kitty style)', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[1;3D', cb); + // Then + expect(cb.calls).toEqual(['wordLeft']); + }); + + it('should detect CSI 1;3C as word right (iTerm2/Kitty style)', () => { + // Given + const cb = createCallbacks(); + // When + parseInputData('\x1B[1;3C', cb); + // Then + expect(cb.calls).toEqual(['wordRight']); + }); + + it('should not insert characters for option+arrow sequences', () => { + // Given + const cb = createCallbacks(); + // When: ESC b should not produce 'char:b' + parseInputData('\x1Bb\x1Bf', cb); + // Then + expect(cb.calls).toEqual(['wordLeft', 'wordRight']); + expect(cb.calls).not.toContain('char:b'); + expect(cb.calls).not.toContain('char:f'); + }); + }); +}); + +describe('readMultilineInput cursor navigation', () => { + let savedIsTTY: boolean | undefined; + let savedIsRaw: boolean | undefined; + let savedSetRawMode: typeof process.stdin.setRawMode | undefined; + let savedStdoutWrite: typeof process.stdout.write; + let savedStdinOn: typeof process.stdin.on; + let savedStdinRemoveListener: typeof process.stdin.removeListener; + let savedStdinResume: typeof process.stdin.resume; + let savedStdinPause: typeof process.stdin.pause; + let stdoutCalls: string[]; + + function setupRawStdin(rawInputs: string[]): void { + savedIsTTY = process.stdin.isTTY; + savedIsRaw = process.stdin.isRaw; + savedSetRawMode = process.stdin.setRawMode; + savedStdoutWrite = process.stdout.write; + savedStdinOn = process.stdin.on; + savedStdinRemoveListener = process.stdin.removeListener; + savedStdinResume = process.stdin.resume; + savedStdinPause = process.stdin.pause; + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true }); + process.stdin.setRawMode = vi.fn((mode: boolean) => { + (process.stdin as unknown as { isRaw: boolean }).isRaw = mode; + return process.stdin; + }) as unknown as typeof process.stdin.setRawMode; + stdoutCalls = []; + process.stdout.write = vi.fn((data: string | Uint8Array) => { + stdoutCalls.push(typeof data === 'string' ? data : data.toString()); + return true; + }) as unknown as typeof process.stdout.write; + process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume; + process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause; + + let currentHandler: ((data: Buffer) => void) | null = null; + let inputIndex = 0; + + process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') { + currentHandler = handler as (data: Buffer) => void; + if (inputIndex < rawInputs.length) { + const data = rawInputs[inputIndex]!; + inputIndex++; + queueMicrotask(() => { + if (currentHandler) { + currentHandler(Buffer.from(data, 'utf-8')); + } + }); + } + } + return process.stdin; + }) as typeof process.stdin.on); + + process.stdin.removeListener = vi.fn(((event: string) => { + if (event === 'data') { + currentHandler = null; + } + return process.stdin; + }) as typeof process.stdin.removeListener); + } + + function restoreStdin(): void { + if (savedIsTTY !== undefined) { + Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true }); + } + if (savedIsRaw !== undefined) { + Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true }); + } + if (savedSetRawMode) process.stdin.setRawMode = savedSetRawMode; + if (savedStdoutWrite) process.stdout.write = savedStdoutWrite; + if (savedStdinOn) process.stdin.on = savedStdinOn; + if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener; + if (savedStdinResume) process.stdin.resume = savedStdinResume; + if (savedStdinPause) process.stdin.pause = savedStdinPause; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreStdin(); + }); + + // We need to dynamically import after mocking stdin + async function callReadMultilineInput(prompt: string): Promise { + const { readMultilineInput } = await import('../features/interactive/lineEditor.js'); + return readMultilineInput(prompt); + } + + describe('left arrow line wrap', () => { + it('should move to end of previous line when at line start', async () => { + // Given: "abc\ndef" with cursor at start of "def", press left → cursor at end of "abc" (pos 3) + // Type "abc", Shift+Enter, "def", Home (to line start of "def"), Left, type "X", Enter + // "abc" + "\n" + "def" → left wraps to end of "abc" → insert "X" at pos 3 → "abcX\ndef" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[H\x1B[DX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX\ndef'); + }); + + it('should not wrap when at start of first line', async () => { + // Given: "abc", Home, Left (should do nothing at pos 0), type "X", Enter + setupRawStdin([ + 'abc\x1B[H\x1B[DX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('Xabc'); + }); + }); + + describe('right arrow line wrap', () => { + it('should move to start of next line when at line end', async () => { + // Given: "abc\ndef", cursor at end of "abc" (pos 3), press right → cursor at start of "def" (pos 4) + // Type "abc", Shift+Enter, "def", then navigate: Home → start of "def", Up → same col in "abc"=start, + // End → end of "abc", Right → wraps to start of "def", type "X", Enter + // Result: "abc\nXdef" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[H\x1B[A\x1B[F\x1B[CX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abc\nXdef'); + }); + + it('should not wrap when at end of last line', async () => { + // Given: "abc", End (already at end), Right (no next line), type "X", Enter + setupRawStdin([ + 'abc\x1B[F\x1B[CX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX'); + }); + }); + + describe('arrow up', () => { + it('should move to previous line at same column', async () => { + // Given: "abcde\nfgh", cursor at end of "fgh" (col 3), press up → col 3 in "abcde" (pos 3) + // Insert "X" → "abcXde\nfgh" + setupRawStdin([ + 'abcde\x1B[13;2ufgh\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcXde\nfgh'); + }); + + it('should clamp to end of shorter previous line', async () => { + // Given: "ab\ncdefg", cursor at end of "cdefg" (col 5), press up → col 2 (end of "ab") (pos 2) + // Insert "X" → "abX\ncdefg" + setupRawStdin([ + 'ab\x1B[13;2ucdefg\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abX\ncdefg'); + }); + + it('should do nothing when on first line', async () => { + // Given: "abc", press up (no previous line), type "X", Enter + setupRawStdin([ + 'abc\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX'); + }); + }); + + describe('arrow down', () => { + it('should move to next line at same column', async () => { + // Given: "abcde\nfgh", cursor at col 2 of "abcde" (use Home+Right+Right), press down → col 2 in "fgh" + // Insert "X" → "abcde\nfgXh" + // Strategy: type "abcde", Shift+Enter, "fgh", Up (→ end of "abcde" col 3), Home, Right, Right, Down, X, Enter + setupRawStdin([ + 'abcde\x1B[13;2ufgh\x1B[A\x1B[H\x1B[C\x1B[C\x1B[BX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcde\nfgXh'); + }); + + it('should clamp to end of shorter next line', async () => { + // Given: "abcde\nfg", cursor at col 4 in "abcde", press down → col 2 (end of "fg") + // Insert "X" → "abcde\nfgX" + setupRawStdin([ + 'abcde\x1B[13;2ufg\x1B[A\x1B[H\x1B[C\x1B[C\x1B[C\x1B[C\x1B[BX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcde\nfgX'); + }); + + it('should do nothing when on last line', async () => { + // Given: "abc", press down (no next line), type "X", Enter + setupRawStdin([ + 'abc\x1B[BX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX'); + }); + + it('should do nothing when next line has no text beyond newline', async () => { + // Given: "abc" with no next line, down does nothing + // buffer = "abc", lineEnd = 3, buffer.length = 3, so lineEnd >= buffer.length → return + setupRawStdin([ + 'abc\x1B[BX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX'); + }); + }); + + describe('terminal escape sequences for line navigation', () => { + it('should emit CUU and CHA when moving up', async () => { + // Given: "ab\ncd", cursor at end of "cd", press up + setupRawStdin([ + 'ab\x1B[13;2ucd\x1B[A\r', + ]); + + // When + await callReadMultilineInput('> '); + + // Then: should contain \x1B[A (cursor up) and \x1B[{n}G (cursor horizontal absolute) + const hasUpMove = stdoutCalls.some(c => c === '\x1B[A'); + const hasCha = stdoutCalls.some(c => /^\x1B\[\d+G$/.test(c)); + expect(hasUpMove).toBe(true); + expect(hasCha).toBe(true); + }); + + it('should emit CUD and CHA when moving down', async () => { + // Given: "ab\ncd", cursor at end of "ab" (navigate up then down) + setupRawStdin([ + 'ab\x1B[13;2ucd\x1B[A\x1B[B\r', + ]); + + // When + await callReadMultilineInput('> '); + + // Then: should contain \x1B[B (cursor down) and \x1B[{n}G + const hasDownMove = stdoutCalls.some(c => c === '\x1B[B'); + const hasCha = stdoutCalls.some(c => /^\x1B\[\d+G$/.test(c)); + expect(hasDownMove).toBe(true); + expect(hasCha).toBe(true); + }); + + it('should emit CUU and CHA when left wraps to previous line', async () => { + // Given: "ab\ncd", cursor at start of "cd", press left + setupRawStdin([ + 'ab\x1B[13;2ucd\x1B[H\x1B[D\r', + ]); + + // When + await callReadMultilineInput('> '); + + // Then: should contain \x1B[A (up) for wrapping to previous line + const hasUpMove = stdoutCalls.some(c => c === '\x1B[A'); + expect(hasUpMove).toBe(true); + }); + + it('should emit CUD and CHA when right wraps to next line', async () => { + // Given: "ab\ncd", cursor at end of "ab", press right + setupRawStdin([ + 'ab\x1B[13;2ucd\x1B[A\x1B[F\x1B[C\r', + ]); + + // When + await callReadMultilineInput('> '); + + // Then: should contain \x1B[B (down) for wrapping to next line + const hasDownMove = stdoutCalls.some(c => c === '\x1B[B'); + expect(hasDownMove).toBe(true); + }); + }); + + describe('full-width character support', () => { + it('should move cursor by 2 columns for full-width character with arrow left', async () => { + // Given: "あいう", cursor at end (col 6 in display), press left → cursor before "う" (display col 4) + // Insert "X" → "あいXう" + setupRawStdin([ + 'あいう\x1B[DX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('あいXう'); + }); + + it('should emit correct terminal width for backspace on full-width char', async () => { + // Given: "あいう", press backspace → "あい" + setupRawStdin([ + 'あいう\x7F\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('あい'); + // Should move 2 columns back for the full-width character + const hasTwoColBack = stdoutCalls.some(c => c === '\x1B[2D'); + expect(hasTwoColBack).toBe(true); + }); + + it('should navigate up/down correctly with full-width characters', async () => { + // Given: "あいう\nabc", cursor at end of "abc" (display col 3) + // Press up → display col 3 in "あいう" → between "あ" and "い" (buffer pos 1, display col 2) + // because display col 3 falls in the middle of "い" (cols 2-3), findPositionByDisplayColumn stops at col 2 + // Insert "X" → "あXいう\nabc" + setupRawStdin([ + 'あいう\x1B[13;2uabc\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('あXいう\nabc'); + }); + + it('should calculate terminal column correctly with full-width on first line', async () => { + // Given: "あ\nb", cursor at "b", press up → first line, prompt ">" (2 cols) + "あ" (2 cols) = CHA col 3 + // Since target display col 1 < "あ" width 2, cursor goes to pos 0 (before "あ") + // Insert "X" → "Xあ\nb" + setupRawStdin([ + 'あ\x1B[13;2ub\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('Xあ\nb'); + }); + }); + + describe('word movement (option+arrow)', () => { + it('should move left by one word with ESC b', async () => { + // Given: "hello world", cursor at end, press Option+Left → cursor before "world", insert "X" + // Result: "hello Xworld" + setupRawStdin([ + 'hello world\x1BbX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('hello Xworld'); + }); + + it('should move right by one word with ESC f', async () => { + // Given: "hello world", Home, Option+Right → skip "hello" then space → cursor at "world", insert "X" + // Result: "hello Xworld" + setupRawStdin([ + 'hello world\x1B[H\x1BfX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('hello Xworld'); + }); + + it('should not move past line start with word left', async () => { + // Given: "abc\ndef", cursor at start of "def", Option+Left does nothing, type "X" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[H\x1BbX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abc\nXdef'); + }); + + it('should not move past line end with word right', async () => { + // Given: "abc\ndef", cursor at end of "abc" (navigate up from "def"), Option+Right does nothing, type "X" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[A\x1BfX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX\ndef'); + }); + + it('should skip spaces then word chars with word left', async () => { + // Given: "foo bar baz", cursor at end, Option+Left → cursor before "baz" + setupRawStdin([ + 'foo bar baz\x1BbX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('foo bar Xbaz'); + }); + + it('should work with CSI 1;3D format', async () => { + // Given: "hello world", cursor at end, CSI Option+Left → cursor before "world", insert "X" + setupRawStdin([ + 'hello world\x1B[1;3DX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('hello Xworld'); + }); + }); + + describe('three-line navigation', () => { + it('should navigate across three lines with up and down', async () => { + // Given: "abc\ndef\nghi", cursor at end of "ghi" (col 3) + // Press up twice → col 3 in "abc" (clamped to 3), insert "X" → "abcX\ndef\nghi" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[13;2ughi\x1B[A\x1B[AX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abcX\ndef\nghi'); + }); + + it('should navigate down from first line to third line', async () => { + // Given: "abc\ndef\nghi", navigate to first line, then down twice to "ghi" + // Type all, then Up Up (→ first line end col 3), Down Down (→ third line col 3), type "X" + setupRawStdin([ + 'abc\x1B[13;2udef\x1B[13;2ughi\x1B[A\x1B[A\x1B[B\x1B[BX\r', + ]); + + // When + const result = await callReadMultilineInput('> '); + + // Then + expect(result).toBe('abc\ndef\nghiX'); + }); + }); +}); diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index fb2347a..55bcd54 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -10,7 +10,6 @@ * /cancel - Cancel and exit */ -import * as readline from 'node:readline'; import chalk from 'chalk'; import type { Language } from '../../core/models/index.js'; import { @@ -28,6 +27,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; +import { readMultilineInput } from './lineEditor.js'; const log = createLogger('interactive'); /** Shape of interactive UI text */ @@ -176,251 +176,6 @@ async function selectPostSummaryAction( ]); } -/** Escape sequences for terminal protocol control */ -const PASTE_BRACKET_ENABLE = '\x1B[?2004h'; -const PASTE_BRACKET_DISABLE = '\x1B[?2004l'; -// flag 1: Disambiguate escape codes — modified keys (e.g. Shift+Enter) are reported as CSI sequences while unmodified keys (e.g. Enter) remain as legacy codes (\r) -const KITTY_KB_ENABLE = '\x1B[>1u'; -const KITTY_KB_DISABLE = '\x1B[ "\x03" - */ -function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null { - // Kitty CSI-u: [codepoint;modifiersu - const kittyMatch = rest.match(/^\[(\d+);(\d+)u/); - if (kittyMatch) { - const codepoint = Number.parseInt(kittyMatch[1]!, 10); - const modifiers = Number.parseInt(kittyMatch[2]!, 10); - // Kitty modifiers are 1-based; Ctrl bit is 4 in 0-based flags. - const ctrlPressed = (((modifiers - 1) & 4) !== 0); - if (!ctrlPressed) return null; - - const key = String.fromCodePoint(codepoint); - if (!/^[A-Za-z]$/.test(key)) return null; - - const upper = key.toUpperCase(); - const controlCode = upper.charCodeAt(0) & 0x1f; - return { ch: String.fromCharCode(controlCode), consumed: kittyMatch[0].length }; - } - - // xterm modifyOtherKeys: [27;modifiers;codepoint~ - const xtermMatch = rest.match(/^\[27;(\d+);(\d+)~/); - if (!xtermMatch) return null; - - const modifiers = Number.parseInt(xtermMatch[1]!, 10); - const codepoint = Number.parseInt(xtermMatch[2]!, 10); - const ctrlPressed = (((modifiers - 1) & 4) !== 0); - if (!ctrlPressed) return null; - - const key = String.fromCodePoint(codepoint); - if (!/^[A-Za-z]$/.test(key)) return null; - - const upper = key.toUpperCase(); - const controlCode = upper.charCodeAt(0) & 0x1f; - return { ch: String.fromCharCode(controlCode), consumed: xtermMatch[0].length }; -} - -/** - * Parse raw stdin data and process each character/sequence. - * - * Handles escape sequences for paste bracket mode (start/end), - * Kitty keyboard protocol (Shift+Enter), and arrow keys (ignored). - * Regular characters are passed to the onChar callback. - */ -function parseInputData( - data: string, - callbacks: { - onPasteStart: () => void; - onPasteEnd: () => void; - onShiftEnter: () => void; - onChar: (ch: string) => void; - }, -): void { - let i = 0; - while (i < data.length) { - const ch = data[i]!; - - if (ch === '\x1B') { - // Try to match known escape sequences - const rest = data.slice(i + 1); - - if (rest.startsWith(ESC_PASTE_START)) { - callbacks.onPasteStart(); - i += 1 + ESC_PASTE_START.length; - continue; - } - if (rest.startsWith(ESC_PASTE_END)) { - callbacks.onPasteEnd(); - i += 1 + ESC_PASTE_END.length; - continue; - } - if (rest.startsWith(ESC_SHIFT_ENTER)) { - callbacks.onShiftEnter(); - i += 1 + ESC_SHIFT_ENTER.length; - continue; - } - const ctrlKey = decodeCtrlKey(rest); - if (ctrlKey) { - callbacks.onChar(ctrlKey.ch); - i += 1 + ctrlKey.consumed; - continue; - } - // Arrow keys and other CSI sequences: skip \x1B[ + letter/params - if (rest.startsWith('[')) { - const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/); - if (csiMatch) { - i += 1 + csiMatch[0].length; - continue; - } - } - // Unrecognized escape: skip the \x1B - i++; - continue; - } - - callbacks.onChar(ch); - i++; - } -} - -/** - * Read multiline input from the user using raw mode. - * - * Supports: - * - Enter (\r) to confirm and submit input - * - Shift+Enter (Kitty keyboard protocol) to insert a newline - * - Paste bracket mode for correctly handling pasted text with newlines - * - Backspace (\x7F) to delete the last character - * - Ctrl+C (\x03) and Ctrl+D (\x04) to cancel (returns null) - * - * Falls back to readline.question() in non-TTY environments. - */ -function readMultilineInput(prompt: string): Promise { - // Non-TTY fallback: use readline for pipe/CI environments - if (!process.stdin.isTTY) { - return new Promise((resolve) => { - if (process.stdin.readable && !process.stdin.destroyed) { - process.stdin.resume(); - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - let answered = false; - - rl.question(prompt, (answer) => { - answered = true; - rl.close(); - resolve(answer); - }); - - rl.on('close', () => { - if (!answered) { - resolve(null); - } - }); - }); - } - - return new Promise((resolve) => { - let buffer = ''; - let state: InputState = 'normal'; - - const wasRaw = process.stdin.isRaw; - process.stdin.setRawMode(true); - process.stdin.resume(); - - // Enable paste bracket mode and Kitty keyboard protocol - process.stdout.write(PASTE_BRACKET_ENABLE); - process.stdout.write(KITTY_KB_ENABLE); - - // Display the prompt - process.stdout.write(prompt); - - function cleanup(): void { - process.stdin.removeListener('data', onData); - process.stdout.write(PASTE_BRACKET_DISABLE); - process.stdout.write(KITTY_KB_DISABLE); - process.stdin.setRawMode(wasRaw ?? false); - process.stdin.pause(); - } - - function onData(data: Buffer): void { - try { - const str = data.toString('utf-8'); - - parseInputData(str, { - onPasteStart() { - state = 'paste'; - }, - onPasteEnd() { - state = 'normal'; - }, - onShiftEnter() { - buffer += '\n'; - process.stdout.write('\n'); - }, - onChar(ch: string) { - if (state === 'paste') { - if (ch === '\r' || ch === '\n') { - buffer += '\n'; - process.stdout.write('\n'); - } else { - buffer += ch; - process.stdout.write(ch); - } - return; - } - - // NORMAL state - if (ch === '\r') { - // Enter: confirm input - process.stdout.write('\n'); - cleanup(); - resolve(buffer); - return; - } - if (ch === '\x03' || ch === '\x04') { - // Ctrl+C or Ctrl+D: cancel - process.stdout.write('\n'); - cleanup(); - resolve(null); - return; - } - if (ch === '\x7F') { - // Backspace: delete last character - if (buffer.length > 0) { - buffer = buffer.slice(0, -1); - process.stdout.write('\b \b'); - } - return; - } - // Regular character - buffer += ch; - process.stdout.write(ch); - }, - }); - } catch { - cleanup(); - resolve(null); - } - } - - process.stdin.on('data', onData); - }); -} - /** * Call AI with the same pattern as piece execution. * The key requirement is passing onStream — the Agent SDK requires diff --git a/src/features/interactive/lineEditor.ts b/src/features/interactive/lineEditor.ts new file mode 100644 index 0000000..adfc042 --- /dev/null +++ b/src/features/interactive/lineEditor.ts @@ -0,0 +1,561 @@ +/** + * Line editor with cursor management for raw-mode terminal input. + * + * Handles: + * - Escape sequence parsing (Kitty keyboard protocol, paste bracket mode) + * - Cursor-aware buffer editing (insert, delete, move) + * - Terminal rendering via ANSI escape sequences + */ + +import * as readline from 'node:readline'; +import { stripAnsi, getDisplayWidth } from '../../shared/utils/text.js'; + +/** Escape sequences for terminal protocol control */ +const PASTE_BRACKET_ENABLE = '\x1B[?2004h'; +const PASTE_BRACKET_DISABLE = '\x1B[?2004l'; +// flag 1: Disambiguate escape codes — modified keys (e.g. Shift+Enter) are reported +// as CSI sequences while unmodified keys (e.g. Enter) remain as legacy codes (\r) +const KITTY_KB_ENABLE = '\x1B[>1u'; +const KITTY_KB_DISABLE = '\x1B[ "\x03" + */ +function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null { + // Kitty CSI-u: [codepoint;modifiersu + const kittyMatch = rest.match(/^\[(\d+);(\d+)u/); + if (kittyMatch) { + const codepoint = Number.parseInt(kittyMatch[1]!, 10); + const modifiers = Number.parseInt(kittyMatch[2]!, 10); + // Kitty modifiers are 1-based; Ctrl bit is 4 in 0-based flags. + const ctrlPressed = ((modifiers - 1) & 4) !== 0; + if (!ctrlPressed) return null; + + const key = String.fromCodePoint(codepoint); + if (!/^[A-Za-z]$/.test(key)) return null; + + const upper = key.toUpperCase(); + const controlCode = upper.charCodeAt(0) & 0x1f; + return { ch: String.fromCharCode(controlCode), consumed: kittyMatch[0].length }; + } + + // xterm modifyOtherKeys: [27;modifiers;codepoint~ + const xtermMatch = rest.match(/^\[27;(\d+);(\d+)~/); + if (!xtermMatch) return null; + + const modifiers = Number.parseInt(xtermMatch[1]!, 10); + const codepoint = Number.parseInt(xtermMatch[2]!, 10); + const ctrlPressed = ((modifiers - 1) & 4) !== 0; + if (!ctrlPressed) return null; + + const key = String.fromCodePoint(codepoint); + if (!/^[A-Za-z]$/.test(key)) return null; + + const upper = key.toUpperCase(); + const controlCode = upper.charCodeAt(0) & 0x1f; + return { ch: String.fromCharCode(controlCode), consumed: xtermMatch[0].length }; +} + +/** Callbacks for parsed input events */ +export interface InputCallbacks { + onPasteStart: () => void; + onPasteEnd: () => void; + onShiftEnter: () => void; + onArrowLeft: () => void; + onArrowRight: () => void; + onArrowUp: () => void; + onArrowDown: () => void; + onWordLeft: () => void; + onWordRight: () => void; + onHome: () => void; + onEnd: () => void; + onChar: (ch: string) => void; +} + +/** + * Parse raw stdin data into semantic input events. + * + * Handles paste bracket mode, Kitty keyboard protocol, arrow keys, + * Home/End, and Ctrl key combinations. Unknown CSI sequences are skipped. + */ +export function parseInputData(data: string, callbacks: InputCallbacks): void { + let i = 0; + while (i < data.length) { + const ch = data[i]!; + + if (ch === '\x1B') { + const rest = data.slice(i + 1); + + if (rest.startsWith(ESC_PASTE_START)) { + callbacks.onPasteStart(); + i += 1 + ESC_PASTE_START.length; + continue; + } + if (rest.startsWith(ESC_PASTE_END)) { + callbacks.onPasteEnd(); + i += 1 + ESC_PASTE_END.length; + continue; + } + if (rest.startsWith(ESC_SHIFT_ENTER)) { + callbacks.onShiftEnter(); + i += 1 + ESC_SHIFT_ENTER.length; + continue; + } + const ctrlKey = decodeCtrlKey(rest); + if (ctrlKey) { + callbacks.onChar(ctrlKey.ch); + i += 1 + ctrlKey.consumed; + continue; + } + + // Arrow keys + if (rest.startsWith('[D')) { + callbacks.onArrowLeft(); + i += 3; + continue; + } + if (rest.startsWith('[C')) { + callbacks.onArrowRight(); + i += 3; + continue; + } + if (rest.startsWith('[A')) { + callbacks.onArrowUp(); + i += 3; + continue; + } + if (rest.startsWith('[B')) { + callbacks.onArrowDown(); + i += 3; + continue; + } + + // Option+Arrow (CSI modified): \x1B[1;3D (left), \x1B[1;3C (right) + if (rest.startsWith('[1;3D')) { + callbacks.onWordLeft(); + i += 6; + continue; + } + if (rest.startsWith('[1;3C')) { + callbacks.onWordRight(); + i += 6; + continue; + } + + // Option+Arrow (SS3/alt): \x1Bb (left), \x1Bf (right) + if (rest.startsWith('b')) { + callbacks.onWordLeft(); + i += 2; + continue; + } + if (rest.startsWith('f')) { + callbacks.onWordRight(); + i += 2; + continue; + } + + // Home: \x1B[H (CSI) or \x1BOH (SS3/application mode) + if (rest.startsWith('[H') || rest.startsWith('OH')) { + callbacks.onHome(); + i += 3; + continue; + } + + // End: \x1B[F (CSI) or \x1BOF (SS3/application mode) + if (rest.startsWith('[F') || rest.startsWith('OF')) { + callbacks.onEnd(); + i += 3; + continue; + } + + // Unknown CSI sequences: skip + if (rest.startsWith('[')) { + const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/); + if (csiMatch) { + i += 1 + csiMatch[0].length; + continue; + } + } + // Unrecognized escape: skip the \x1B + i++; + continue; + } + + callbacks.onChar(ch); + i++; + } +} + +/** + * Read multiline input from the user using raw mode with cursor management. + * + * Supports: + * - Enter to submit, Shift+Enter to insert newline + * - Paste bracket mode for pasted text with newlines + * - Left/Right arrows, Home/End for cursor movement + * - Ctrl+A/E (line start/end), Ctrl+K/U (kill line), Ctrl+W (delete word) + * - Backspace / Ctrl+H, Ctrl+C / Ctrl+D (cancel) + * + * Falls back to readline.question() in non-TTY environments. + */ +export function readMultilineInput(prompt: string): Promise { + if (!process.stdin.isTTY) { + return new Promise((resolve) => { + if (process.stdin.readable && !process.stdin.destroyed) { + process.stdin.resume(); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + let answered = false; + + rl.question(prompt, (answer) => { + answered = true; + rl.close(); + resolve(answer); + }); + + rl.on('close', () => { + if (!answered) { + resolve(null); + } + }); + }); + } + + return new Promise((resolve) => { + let buffer = ''; + let cursorPos = 0; + let state: InputState = 'normal'; + + const wasRaw = process.stdin.isRaw; + process.stdin.setRawMode(true); + process.stdin.resume(); + + process.stdout.write(PASTE_BRACKET_ENABLE); + process.stdout.write(KITTY_KB_ENABLE); + process.stdout.write(prompt); + + // --- Buffer position helpers --- + + function getLineStart(): number { + const lastNl = buffer.lastIndexOf('\n', cursorPos - 1); + return lastNl + 1; + } + + function getLineEnd(): number { + const nextNl = buffer.indexOf('\n', cursorPos); + return nextNl >= 0 ? nextNl : buffer.length; + } + + function getLineStartAt(pos: number): number { + const lastNl = buffer.lastIndexOf('\n', pos - 1); + return lastNl + 1; + } + + function getLineEndAt(pos: number): number { + const nextNl = buffer.indexOf('\n', pos); + return nextNl >= 0 ? nextNl : buffer.length; + } + + /** Display width from line start to cursor */ + function getDisplayColumn(): number { + return getDisplayWidth(buffer.slice(getLineStart(), cursorPos)); + } + + const promptWidth = getDisplayWidth(stripAnsi(prompt)); + + /** Terminal column (1-based) for a given buffer position */ + function getTerminalColumn(pos: number): number { + const lineStart = getLineStartAt(pos); + const col = getDisplayWidth(buffer.slice(lineStart, pos)); + const isFirstLine = lineStart === 0; + return isFirstLine ? promptWidth + col + 1 : col + 1; + } + + /** Find the buffer position in a line that matches a target display column */ + function findPositionByDisplayColumn(lineStart: number, lineEnd: number, targetDisplayCol: number): number { + let displayCol = 0; + let pos = lineStart; + for (const ch of buffer.slice(lineStart, lineEnd)) { + const w = getDisplayWidth(ch); + if (displayCol + w > targetDisplayCol) break; + displayCol += w; + pos += ch.length; + } + return pos; + } + + // --- Terminal output helpers --- + + function rerenderFromCursor(): void { + const afterCursor = buffer.slice(cursorPos, getLineEnd()); + if (afterCursor.length > 0) { + process.stdout.write(afterCursor); + } + process.stdout.write('\x1B[K'); + const afterWidth = getDisplayWidth(afterCursor); + if (afterWidth > 0) { + process.stdout.write(`\x1B[${afterWidth}D`); + } + } + + function cleanup(): void { + process.stdin.removeListener('data', onData); + process.stdout.write(PASTE_BRACKET_DISABLE); + process.stdout.write(KITTY_KB_DISABLE); + process.stdin.setRawMode(wasRaw ?? false); + process.stdin.pause(); + } + + // --- Cursor movement --- + + function moveCursorToLineStart(): void { + const displayOffset = getDisplayColumn(); + if (displayOffset > 0) { + cursorPos = getLineStart(); + process.stdout.write(`\x1B[${displayOffset}D`); + } + } + + function moveCursorToLineEnd(): void { + const lineEnd = getLineEnd(); + const displayOffset = getDisplayWidth(buffer.slice(cursorPos, lineEnd)); + if (displayOffset > 0) { + cursorPos = lineEnd; + process.stdout.write(`\x1B[${displayOffset}C`); + } + } + + // --- Buffer editing --- + + function insertAt(pos: number, text: string): void { + buffer = buffer.slice(0, pos) + text + buffer.slice(pos); + } + + function deleteRange(start: number, end: number): void { + buffer = buffer.slice(0, start) + buffer.slice(end); + } + + function insertChar(ch: string): void { + insertAt(cursorPos, ch); + cursorPos += ch.length; + process.stdout.write(ch); + if (cursorPos < getLineEnd()) { + const afterCursor = buffer.slice(cursorPos, getLineEnd()); + process.stdout.write(afterCursor); + process.stdout.write('\x1B[K'); + const afterWidth = getDisplayWidth(afterCursor); + process.stdout.write(`\x1B[${afterWidth}D`); + } + } + + function deleteCharBefore(): void { + if (cursorPos <= getLineStart()) return; + const charWidth = getDisplayWidth(buffer[cursorPos - 1]!); + deleteRange(cursorPos - 1, cursorPos); + cursorPos--; + process.stdout.write(`\x1B[${charWidth}D`); + rerenderFromCursor(); + } + + function deleteToLineEnd(): void { + const lineEnd = getLineEnd(); + if (cursorPos < lineEnd) { + deleteRange(cursorPos, lineEnd); + process.stdout.write('\x1B[K'); + } + } + + function deleteToLineStart(): void { + const lineStart = getLineStart(); + if (cursorPos > lineStart) { + const deletedWidth = getDisplayWidth(buffer.slice(lineStart, cursorPos)); + deleteRange(lineStart, cursorPos); + cursorPos = lineStart; + process.stdout.write(`\x1B[${deletedWidth}D`); + rerenderFromCursor(); + } + } + + function deleteWord(): void { + const lineStart = getLineStart(); + let end = cursorPos; + while (end > lineStart && buffer[end - 1] === ' ') end--; + while (end > lineStart && buffer[end - 1] !== ' ') end--; + if (end < cursorPos) { + const deletedWidth = getDisplayWidth(buffer.slice(end, cursorPos)); + deleteRange(end, cursorPos); + cursorPos = end; + process.stdout.write(`\x1B[${deletedWidth}D`); + rerenderFromCursor(); + } + } + + function insertNewline(): void { + const afterCursorOnLine = buffer.slice(cursorPos, getLineEnd()); + insertAt(cursorPos, '\n'); + cursorPos++; + process.stdout.write('\x1B[K'); + process.stdout.write('\n'); + if (afterCursorOnLine.length > 0) { + process.stdout.write(afterCursorOnLine); + const afterWidth = getDisplayWidth(afterCursorOnLine); + process.stdout.write(`\x1B[${afterWidth}D`); + } + } + + // --- Input dispatch --- + + function onData(data: Buffer): void { + try { + const str = data.toString('utf-8'); + + parseInputData(str, { + onPasteStart() { state = 'paste'; }, + onPasteEnd() { + state = 'normal'; + rerenderFromCursor(); + }, + onShiftEnter() { insertNewline(); }, + onArrowLeft() { + if (state !== 'normal') return; + if (cursorPos > getLineStart()) { + const charWidth = getDisplayWidth(buffer[cursorPos - 1]!); + cursorPos--; + process.stdout.write(`\x1B[${charWidth}D`); + } else if (getLineStart() > 0) { + cursorPos = getLineStart() - 1; + const col = getTerminalColumn(cursorPos); + process.stdout.write('\x1B[A'); + process.stdout.write(`\x1B[${col}G`); + } + }, + onArrowRight() { + if (state !== 'normal') return; + if (cursorPos < getLineEnd()) { + const charWidth = getDisplayWidth(buffer[cursorPos]!); + cursorPos++; + process.stdout.write(`\x1B[${charWidth}C`); + } else if (cursorPos < buffer.length && buffer[cursorPos] === '\n') { + cursorPos++; + const col = getTerminalColumn(cursorPos); + process.stdout.write('\x1B[B'); + process.stdout.write(`\x1B[${col}G`); + } + }, + onArrowUp() { + if (state !== 'normal') return; + const lineStart = getLineStart(); + if (lineStart === 0) return; + const displayCol = getDisplayColumn(); + const prevLineStart = getLineStartAt(lineStart - 1); + const prevLineEnd = lineStart - 1; + cursorPos = findPositionByDisplayColumn(prevLineStart, prevLineEnd, displayCol); + const termCol = getTerminalColumn(cursorPos); + process.stdout.write('\x1B[A'); + process.stdout.write(`\x1B[${termCol}G`); + }, + onArrowDown() { + if (state !== 'normal') return; + const lineEnd = getLineEnd(); + if (lineEnd >= buffer.length) return; + const displayCol = getDisplayColumn(); + const nextLineStart = lineEnd + 1; + const nextLineEnd = getLineEndAt(nextLineStart); + cursorPos = findPositionByDisplayColumn(nextLineStart, nextLineEnd, displayCol); + const termCol = getTerminalColumn(cursorPos); + process.stdout.write('\x1B[B'); + process.stdout.write(`\x1B[${termCol}G`); + }, + onWordLeft() { + if (state !== 'normal') return; + const lineStart = getLineStart(); + if (cursorPos <= lineStart) return; + let pos = cursorPos; + while (pos > lineStart && buffer[pos - 1] === ' ') pos--; + while (pos > lineStart && buffer[pos - 1] !== ' ') pos--; + const moveWidth = getDisplayWidth(buffer.slice(pos, cursorPos)); + cursorPos = pos; + process.stdout.write(`\x1B[${moveWidth}D`); + }, + onWordRight() { + if (state !== 'normal') return; + const lineEnd = getLineEnd(); + if (cursorPos >= lineEnd) return; + let pos = cursorPos; + while (pos < lineEnd && buffer[pos] !== ' ') pos++; + while (pos < lineEnd && buffer[pos] === ' ') pos++; + const moveWidth = getDisplayWidth(buffer.slice(cursorPos, pos)); + cursorPos = pos; + process.stdout.write(`\x1B[${moveWidth}C`); + }, + onHome() { + if (state !== 'normal') return; + moveCursorToLineStart(); + }, + onEnd() { + if (state !== 'normal') return; + moveCursorToLineEnd(); + }, + onChar(ch: string) { + if (state === 'paste') { + if (ch === '\r' || ch === '\n') { + insertAt(cursorPos, '\n'); + cursorPos++; + process.stdout.write('\n'); + } else { + insertAt(cursorPos, ch); + cursorPos++; + process.stdout.write(ch); + } + return; + } + + // Submit + if (ch === '\r') { + process.stdout.write('\n'); + cleanup(); + resolve(buffer); + return; + } + // Cancel + if (ch === '\x03' || ch === '\x04') { + process.stdout.write('\n'); + cleanup(); + resolve(null); + return; + } + // Editing + if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; } + if (ch === '\x01') { moveCursorToLineStart(); return; } + if (ch === '\x05') { moveCursorToLineEnd(); return; } + if (ch === '\x0B') { deleteToLineEnd(); return; } + if (ch === '\x15') { deleteToLineStart(); return; } + if (ch === '\x17') { deleteWord(); return; } + // Ignore unknown control characters + if (ch.charCodeAt(0) < 0x20) return; + // Regular character + insertChar(ch); + }, + }); + } catch { + cleanup(); + resolve(null); + } + } + + process.stdin.on('data', onData); + }); +}