Merge pull request #141 from nrslib/takt/#131/github-issue-131-tasuku-intara
github-issue-131-tasuku-intara Close #131
This commit is contained in:
commit
7c5936ee76
@ -2,7 +2,7 @@
|
|||||||
* Tests for interactive mode
|
* Tests for interactive mode
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||||
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||||
@ -49,56 +49,118 @@ vi.mock('../shared/prompt/index.js', () => ({
|
|||||||
selectOption: vi.fn(),
|
selectOption: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock readline to simulate user input
|
|
||||||
vi.mock('node:readline', () => ({
|
|
||||||
createInterface: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import { getProvider } from '../infra/providers/index.js';
|
import { getProvider } from '../infra/providers/index.js';
|
||||||
import { interactiveMode } from '../features/interactive/index.js';
|
import { interactiveMode } from '../features/interactive/index.js';
|
||||||
import { selectOption } from '../shared/prompt/index.js';
|
import { selectOption } from '../shared/prompt/index.js';
|
||||||
|
|
||||||
const mockGetProvider = vi.mocked(getProvider);
|
const mockGetProvider = vi.mocked(getProvider);
|
||||||
const mockCreateInterface = vi.mocked(createInterface);
|
|
||||||
const mockSelectOption = vi.mocked(selectOption);
|
const mockSelectOption = vi.mocked(selectOption);
|
||||||
|
|
||||||
/** Helper to set up a sequence of readline inputs */
|
// Store original stdin/stdout properties to restore
|
||||||
function setupInputSequence(inputs: (string | null)[]): void {
|
let savedIsTTY: boolean | undefined;
|
||||||
let callIndex = 0;
|
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;
|
||||||
|
|
||||||
mockCreateInterface.mockImplementation(() => {
|
/**
|
||||||
const input = callIndex < inputs.length ? inputs[callIndex] : null;
|
* Captures the current data handler and provides sendData.
|
||||||
callIndex++;
|
*
|
||||||
|
* When readMultilineInput registers process.stdin.on('data', handler),
|
||||||
|
* this captures the handler so tests can send raw input data.
|
||||||
|
*
|
||||||
|
* rawInputs: array of raw strings to send sequentially. Each time a new
|
||||||
|
* 'data' listener is registered, the next raw input is sent via queueMicrotask.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
|
||||||
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
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;
|
||||||
|
process.stdout.write = vi.fn(() => 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;
|
||||||
|
|
||||||
const rlMock = {
|
let currentHandler: ((data: Buffer) => void) | null = null;
|
||||||
question: vi.fn((_prompt: string, callback: (answer: string) => void) => {
|
let inputIndex = 0;
|
||||||
if (input === null) {
|
|
||||||
// Simulate EOF (Ctrl+D) — emit close event asynchronously
|
|
||||||
// so that the on('close') listener is registered first
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const closeListeners = listeners['close'] || [];
|
|
||||||
for (const listener of closeListeners) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
callback(input);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
close: vi.fn(),
|
|
||||||
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
|
|
||||||
if (!listeners[event]) {
|
|
||||||
listeners[event] = [];
|
|
||||||
}
|
|
||||||
listeners[event]!.push(listener);
|
|
||||||
return rlMock;
|
|
||||||
}),
|
|
||||||
} as unknown as ReturnType<typeof createInterface>;
|
|
||||||
|
|
||||||
return rlMock;
|
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
currentHandler = handler as (data: Buffer) => void;
|
||||||
|
// Send next input when handler is registered
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert user-level inputs to raw stdin data.
|
||||||
|
*
|
||||||
|
* Each element is either:
|
||||||
|
* - A string: sent as typed characters + Enter (\r)
|
||||||
|
* - null: sent as Ctrl+D (\x04)
|
||||||
|
*/
|
||||||
|
function toRawInputs(inputs: (string | null)[]): string[] {
|
||||||
|
return inputs.map((input) => {
|
||||||
|
if (input === null) return '\x04';
|
||||||
|
return input + '\r';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,14 +186,17 @@ function setupMockProvider(responses: string[]): void {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// selectPostSummaryAction uses selectOption with action values
|
|
||||||
mockSelectOption.mockResolvedValue('execute');
|
mockSelectOption.mockResolvedValue('execute');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreStdin();
|
||||||
|
});
|
||||||
|
|
||||||
describe('interactiveMode', () => {
|
describe('interactiveMode', () => {
|
||||||
it('should return action=cancel when user types /cancel', async () => {
|
it('should return action=cancel when user types /cancel', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['/cancel']);
|
setupRawStdin(toRawInputs(['/cancel']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -144,7 +209,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should return action=cancel on EOF (Ctrl+D)', async () => {
|
it('should return action=cancel on EOF (Ctrl+D)', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence([null]);
|
setupRawStdin(toRawInputs([null]));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -156,7 +221,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should call provider with allowed tools for codebase exploration', async () => {
|
it('should call provider with allowed tools for codebase exploration', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['fix the login bug', '/go']);
|
setupRawStdin(toRawInputs(['fix the login bug', '/go']));
|
||||||
setupMockProvider(['What kind of login bug?']);
|
setupMockProvider(['What kind of login bug?']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -175,7 +240,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should return action=execute with task on /go after conversation', async () => {
|
it('should return action=execute with task on /go after conversation', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['add auth feature', '/go']);
|
setupRawStdin(toRawInputs(['add auth feature', '/go']));
|
||||||
setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']);
|
setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -188,7 +253,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should reject /go with no prior conversation', async () => {
|
it('should reject /go with no prior conversation', async () => {
|
||||||
// Given: /go immediately, then /cancel to exit
|
// Given: /go immediately, then /cancel to exit
|
||||||
setupInputSequence(['/go', '/cancel']);
|
setupRawStdin(toRawInputs(['/go', '/cancel']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -199,8 +264,8 @@ describe('interactiveMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should skip empty input', async () => {
|
it('should skip empty input', async () => {
|
||||||
// Given: empty line, then actual input, then /go
|
// Given: empty line (just Enter), then actual input, then /go
|
||||||
setupInputSequence(['', 'do something', '/go']);
|
setupRawStdin(toRawInputs(['', 'do something', '/go']));
|
||||||
setupMockProvider(['Sure, what exactly?', 'Do something with the clarified scope.']);
|
setupMockProvider(['Sure, what exactly?', 'Do something with the clarified scope.']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -214,7 +279,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should accumulate conversation history across multiple turns', async () => {
|
it('should accumulate conversation history across multiple turns', async () => {
|
||||||
// Given: two user messages before /go
|
// Given: two user messages before /go
|
||||||
setupInputSequence(['first message', 'second message', '/go']);
|
setupRawStdin(toRawInputs(['first message', 'second message', '/go']));
|
||||||
setupMockProvider(['response to first', 'response to second', 'Summarized task.']);
|
setupMockProvider(['response to first', 'response to second', 'Summarized task.']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -234,7 +299,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should send only current input per turn (session handles history)', async () => {
|
it('should send only current input per turn (session handles history)', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['first msg', 'second msg', '/go']);
|
setupRawStdin(toRawInputs(['first msg', 'second msg', '/go']));
|
||||||
setupMockProvider(['AI reply 1', 'AI reply 2']);
|
setupMockProvider(['AI reply 1', 'AI reply 2']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -248,7 +313,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should inject policy into user messages', async () => {
|
it('should inject policy into user messages', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['test message', '/cancel']);
|
setupRawStdin(toRawInputs(['test message', '/cancel']));
|
||||||
setupMockProvider(['response']);
|
setupMockProvider(['response']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -265,7 +330,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should process initialInput as first message before entering loop', async () => {
|
it('should process initialInput as first message before entering loop', async () => {
|
||||||
// Given: initialInput provided, then user types /go
|
// Given: initialInput provided, then user types /go
|
||||||
setupInputSequence(['/go']);
|
setupRawStdin(toRawInputs(['/go']));
|
||||||
setupMockProvider(['What do you mean by "a"?', 'Clarify task for "a".']);
|
setupMockProvider(['What do you mean by "a"?', 'Clarify task for "a".']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -285,7 +350,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should send only current input for subsequent turns after initialInput', async () => {
|
it('should send only current input for subsequent turns after initialInput', async () => {
|
||||||
// Given: initialInput, then follow-up, then /go
|
// Given: initialInput, then follow-up, then /go
|
||||||
setupInputSequence(['fix the login page', '/go']);
|
setupRawStdin(toRawInputs(['fix the login page', '/go']));
|
||||||
setupMockProvider(['What about "a"?', 'Got it, fixing login page.', 'Fix login page with clarified scope.']);
|
setupMockProvider(['What about "a"?', 'Got it, fixing login page.', 'Fix login page with clarified scope.']);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -307,7 +372,7 @@ describe('interactiveMode', () => {
|
|||||||
describe('/play command', () => {
|
describe('/play command', () => {
|
||||||
it('should return action=execute with task on /play command', async () => {
|
it('should return action=execute with task on /play command', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['/play implement login feature']);
|
setupRawStdin(toRawInputs(['/play implement login feature']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -320,7 +385,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should show error when /play has no task content', async () => {
|
it('should show error when /play has no task content', async () => {
|
||||||
// Given: /play without task, then /cancel to exit
|
// Given: /play without task, then /cancel to exit
|
||||||
setupInputSequence(['/play', '/cancel']);
|
setupRawStdin(toRawInputs(['/play', '/cancel']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -332,7 +397,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should handle /play with leading/trailing spaces', async () => {
|
it('should handle /play with leading/trailing spaces', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['/play test task ']);
|
setupRawStdin(toRawInputs(['/play test task ']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
@ -345,14 +410,14 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should skip AI summary when using /play', async () => {
|
it('should skip AI summary when using /play', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['/play quick task']);
|
setupRawStdin(toRawInputs(['/play quick task']));
|
||||||
setupMockProvider([]);
|
setupMockProvider([]);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
const result = await interactiveMode('/project');
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
// Then: provider should NOT have been called (no summary needed)
|
// Then: provider should NOT have been called (no summary needed)
|
||||||
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> };
|
||||||
expect(mockProvider._call).not.toHaveBeenCalled();
|
expect(mockProvider._call).not.toHaveBeenCalled();
|
||||||
expect(result.action).toBe('execute');
|
expect(result.action).toBe('execute');
|
||||||
expect(result.task).toBe('quick task');
|
expect(result.task).toBe('quick task');
|
||||||
@ -362,7 +427,7 @@ describe('interactiveMode', () => {
|
|||||||
describe('action selection after /go', () => {
|
describe('action selection after /go', () => {
|
||||||
it('should return action=create_issue when user selects create issue', async () => {
|
it('should return action=create_issue when user selects create issue', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['describe task', '/go']);
|
setupRawStdin(toRawInputs(['describe task', '/go']));
|
||||||
setupMockProvider(['response', 'Summarized task.']);
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
mockSelectOption.mockResolvedValue('create_issue');
|
mockSelectOption.mockResolvedValue('create_issue');
|
||||||
|
|
||||||
@ -376,7 +441,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should return action=save_task when user selects save task', async () => {
|
it('should return action=save_task when user selects save task', async () => {
|
||||||
// Given
|
// Given
|
||||||
setupInputSequence(['describe task', '/go']);
|
setupRawStdin(toRawInputs(['describe task', '/go']));
|
||||||
setupMockProvider(['response', 'Summarized task.']);
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
mockSelectOption.mockResolvedValue('save_task');
|
mockSelectOption.mockResolvedValue('save_task');
|
||||||
|
|
||||||
@ -390,7 +455,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should continue editing when user selects continue', async () => {
|
it('should continue editing when user selects continue', async () => {
|
||||||
// Given: user selects 'continue' first, then cancels
|
// Given: user selects 'continue' first, then cancels
|
||||||
setupInputSequence(['describe task', '/go', '/cancel']);
|
setupRawStdin(toRawInputs(['describe task', '/go', '/cancel']));
|
||||||
setupMockProvider(['response', 'Summarized task.']);
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
mockSelectOption.mockResolvedValueOnce('continue');
|
mockSelectOption.mockResolvedValueOnce('continue');
|
||||||
|
|
||||||
@ -403,7 +468,7 @@ describe('interactiveMode', () => {
|
|||||||
|
|
||||||
it('should continue editing when user presses ESC (null)', async () => {
|
it('should continue editing when user presses ESC (null)', async () => {
|
||||||
// Given: selectOption returns null (ESC), then user cancels
|
// Given: selectOption returns null (ESC), then user cancels
|
||||||
setupInputSequence(['describe task', '/go', '/cancel']);
|
setupRawStdin(toRawInputs(['describe task', '/go', '/cancel']));
|
||||||
setupMockProvider(['response', 'Summarized task.']);
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
mockSelectOption.mockResolvedValueOnce(null);
|
mockSelectOption.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
@ -414,4 +479,115 @@ describe('interactiveMode', () => {
|
|||||||
expect(result.action).toBe('cancel');
|
expect(result.action).toBe('cancel');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('multiline input', () => {
|
||||||
|
it('should handle paste with newlines via bracket paste mode', async () => {
|
||||||
|
// Given: pasted text with newlines, then /cancel
|
||||||
|
// \x1B[200~ starts paste, \x1B[201~ ends paste
|
||||||
|
setupRawStdin([
|
||||||
|
'\x1B[200~line1\nline2\nline3\x1B[201~\r',
|
||||||
|
'/cancel\r',
|
||||||
|
]);
|
||||||
|
setupMockProvider(['Got multiline input']);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: the pasted text should have been sent to AI with newlines preserved
|
||||||
|
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('line1\nline2\nline3');
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Shift+Enter (Kitty protocol) for newline insertion', async () => {
|
||||||
|
// Given: text with Shift+Enter (\x1B[13;2u) for newline
|
||||||
|
setupRawStdin([
|
||||||
|
'hello\x1B[13;2uworld\r',
|
||||||
|
'/cancel\r',
|
||||||
|
]);
|
||||||
|
setupMockProvider(['Got multiline input']);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: input should contain a newline between hello and world
|
||||||
|
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\nworld');
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle backspace to delete last character', async () => {
|
||||||
|
// Given: type "ab", backspace (\x7F), type "c", Enter
|
||||||
|
setupRawStdin([
|
||||||
|
'ab\x7Fc\r',
|
||||||
|
'/cancel\r',
|
||||||
|
]);
|
||||||
|
setupMockProvider(['response']);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: input should be "ac" (b was deleted by backspace)
|
||||||
|
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 handle Ctrl+C to cancel input', async () => {
|
||||||
|
// Given: Ctrl+C during input
|
||||||
|
setupRawStdin(['\x03']);
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: should cancel
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+D to cancel input', async () => {
|
||||||
|
// Given: Ctrl+D during input
|
||||||
|
setupRawStdin(['\x04']);
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: should cancel
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore arrow keys in normal mode', async () => {
|
||||||
|
// Given: text with arrow keys interspersed (arrows are ignored)
|
||||||
|
setupRawStdin([
|
||||||
|
'he\x1B[Dllo\x1B[C\r',
|
||||||
|
'/cancel\r',
|
||||||
|
]);
|
||||||
|
setupMockProvider(['response']);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: arrows are ignored, text is "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 handle empty input on Enter', async () => {
|
||||||
|
// Given: just Enter (empty), then /cancel
|
||||||
|
setupRawStdin(toRawInputs(['', '/cancel']));
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await interactiveMode('/project');
|
||||||
|
|
||||||
|
// Then: empty input is skipped, falls through to /cancel
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -176,36 +176,203 @@ 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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a single line of input from the user.
|
* Parse raw stdin data and process each character/sequence.
|
||||||
* Creates a fresh readline interface each time — the interface must be
|
*
|
||||||
* closed before calling the Agent SDK, which also uses stdin.
|
* Handles escape sequences for paste bracket mode (start/end),
|
||||||
* Returns null on EOF (Ctrl+D).
|
* Kitty keyboard protocol (Shift+Enter), and arrow keys (ignored).
|
||||||
|
* Regular characters are passed to the onChar callback.
|
||||||
*/
|
*/
|
||||||
function readLine(prompt: string): Promise<string | null> {
|
function parseInputData(
|
||||||
return new Promise((resolve) => {
|
data: string,
|
||||||
if (process.stdin.readable && !process.stdin.destroyed) {
|
callbacks: {
|
||||||
process.stdin.resume();
|
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;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
callbacks.onChar(ch);
|
||||||
input: process.stdin,
|
i++;
|
||||||
output: process.stdout,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let answered = false;
|
return new Promise((resolve) => {
|
||||||
|
let buffer = '';
|
||||||
|
let state: InputState = 'normal';
|
||||||
|
|
||||||
rl.question(prompt, (answer) => {
|
const wasRaw = process.stdin.isRaw;
|
||||||
answered = true;
|
process.stdin.setRawMode(true);
|
||||||
rl.close();
|
process.stdin.resume();
|
||||||
resolve(answer);
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on('close', () => {
|
// Enable paste bracket mode and Kitty keyboard protocol
|
||||||
if (!answered) {
|
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);
|
resolve(null);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
process.stdin.on('data', onData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,7 +548,7 @@ export async function interactiveMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const input = await readLine(chalk.green('> '));
|
const input = await readMultilineInput(chalk.green('> '));
|
||||||
|
|
||||||
// EOF (Ctrl+D)
|
// EOF (Ctrl+D)
|
||||||
if (input === null) {
|
if (input === null) {
|
||||||
@ -451,7 +618,6 @@ export async function interactiveMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Regular input — send to AI
|
// Regular input — send to AI
|
||||||
// readline is already closed at this point, so stdin is free for SDK
|
|
||||||
history.push({ role: 'user', content: trimmed });
|
history.push({ role: 'user', content: trimmed });
|
||||||
|
|
||||||
log.debug('Sending to AI', { messageCount: history.length, sessionId });
|
log.debug('Sending to AI', { messageCount: history.length, sessionId });
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user