ライン編集を分離し、カーソル管理・行間移動・Option+Arrow単語移動を実装
interactive.ts から入力処理を lineEditor.ts に抽出。矢印キーによるカーソル移動、 行頭/行末折り返し、上下キーによる行間移動、全角文字の表示幅対応、 Option+Arrow(ESC b/f, CSI 1;3D/C)による単語単位移動を追加。
This commit is contained in:
parent
b5ec0762b6
commit
71cc3d8874
@ -608,10 +608,11 @@ describe('interactiveMode', () => {
|
|||||||
expect(result.action).toBe('cancel');
|
expect(result.action).toBe('cancel');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore arrow keys in normal mode', async () => {
|
it('should move cursor with arrow keys and insert at position', async () => {
|
||||||
// Given: text with arrow keys interspersed (arrows are ignored)
|
// Given: type "hllo", left 3 → cursor at 1, type "e", Enter
|
||||||
|
// buffer: "h" + "e" + "llo" = "hello"
|
||||||
setupRawStdin([
|
setupRawStdin([
|
||||||
'he\x1B[Dllo\x1B[C\r',
|
'hllo\x1B[D\x1B[D\x1B[De\r',
|
||||||
'/cancel\r',
|
'/cancel\r',
|
||||||
]);
|
]);
|
||||||
setupMockProvider(['response']);
|
setupMockProvider(['response']);
|
||||||
@ -619,7 +620,7 @@ describe('interactiveMode', () => {
|
|||||||
// When
|
// When
|
||||||
const result = await interactiveMode('/project');
|
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<typeof vi.fn> };
|
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
|
||||||
const prompt = mockProvider._call.mock.calls[0]?.[0] as string;
|
const prompt = mockProvider._call.mock.calls[0]?.[0] as string;
|
||||||
expect(prompt).toContain('hello');
|
expect(prompt).toContain('hello');
|
||||||
@ -637,5 +638,302 @@ describe('interactiveMode', () => {
|
|||||||
// Then: empty input is skipped, falls through to /cancel
|
// Then: empty input is skipped, falls through to /cancel
|
||||||
expect(result.action).toBe('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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
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<typeof vi.fn> };
|
||||||
|
const prompt = mockProvider._call.mock.calls[0]?.[0] as string;
|
||||||
|
expect(prompt).toContain('abc');
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
614
src/__tests__/lineEditor.test.ts
Normal file
614
src/__tests__/lineEditor.test.ts
Normal file
@ -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<string | null> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,7 +10,6 @@
|
|||||||
* /cancel - Cancel and exit
|
* /cancel - Cancel and exit
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as readline from 'node:readline';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import type { Language } from '../../core/models/index.js';
|
import type { Language } from '../../core/models/index.js';
|
||||||
import {
|
import {
|
||||||
@ -28,6 +27,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
|||||||
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
||||||
import { loadTemplate } from '../../shared/prompts/index.js';
|
import { loadTemplate } from '../../shared/prompts/index.js';
|
||||||
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
||||||
|
import { readMultilineInput } from './lineEditor.js';
|
||||||
const log = createLogger('interactive');
|
const log = createLogger('interactive');
|
||||||
|
|
||||||
/** Shape of interactive UI text */
|
/** 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[<u';
|
|
||||||
|
|
||||||
/** Known escape sequence prefixes for matching */
|
|
||||||
const ESC_PASTE_START = '[200~';
|
|
||||||
const ESC_PASTE_END = '[201~';
|
|
||||||
const ESC_SHIFT_ENTER = '[13;2u';
|
|
||||||
|
|
||||||
type InputState = 'normal' | 'paste';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode Kitty CSI-u key sequence into a control character.
|
|
||||||
* Example: "[99;5u" (Ctrl+C) -> "\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<string | null> {
|
|
||||||
// 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.
|
* Call AI with the same pattern as piece execution.
|
||||||
* The key requirement is passing onStream — the Agent SDK requires
|
* The key requirement is passing onStream — the Agent SDK requires
|
||||||
|
|||||||
561
src/features/interactive/lineEditor.ts
Normal file
561
src/features/interactive/lineEditor.ts
Normal file
@ -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[<u';
|
||||||
|
|
||||||
|
/** Known escape sequence prefixes for matching */
|
||||||
|
const ESC_PASTE_START = '[200~';
|
||||||
|
const ESC_PASTE_END = '[201~';
|
||||||
|
const ESC_SHIFT_ENTER = '[13;2u';
|
||||||
|
|
||||||
|
type InputState = 'normal' | 'paste';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode Kitty CSI-u key sequence into a control character.
|
||||||
|
* Example: "[99;5u" (Ctrl+C) -> "\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<string | null> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user