github-issue-155-taktno-moodo (#158)
* caffeinate に -d フラグを追加し、ディスプレイスリープ中の App Nap によるプロセス凍結を防止 * takt 対話モードの save_task を takt add と同じ worktree 設定フローに統一 takt 対話モードで Save Task を選択した際に worktree/branch/auto_pr の 設定プロンプトがスキップされ、takt run で clone なしに実行されて成果物が 消失するバグを修正。promptWorktreeSettings() を共通関数として抽出し、 saveTaskFromInteractive() と addTask() の両方から使用するようにした。 * Release v0.9.0 * takt: github-issue-155-taktno-moodo
This commit is contained in:
parent
cdedb4326e
commit
c542dc0896
@ -131,9 +131,11 @@ describe('readMultilineInput cursor navigation', () => {
|
|||||||
let savedStdinRemoveListener: typeof process.stdin.removeListener;
|
let savedStdinRemoveListener: typeof process.stdin.removeListener;
|
||||||
let savedStdinResume: typeof process.stdin.resume;
|
let savedStdinResume: typeof process.stdin.resume;
|
||||||
let savedStdinPause: typeof process.stdin.pause;
|
let savedStdinPause: typeof process.stdin.pause;
|
||||||
|
let savedColumns: number | undefined;
|
||||||
|
let columnsOverridden = false;
|
||||||
let stdoutCalls: string[];
|
let stdoutCalls: string[];
|
||||||
|
|
||||||
function setupRawStdin(rawInputs: string[]): void {
|
function setupRawStdin(rawInputs: string[], termColumns?: number): void {
|
||||||
savedIsTTY = process.stdin.isTTY;
|
savedIsTTY = process.stdin.isTTY;
|
||||||
savedIsRaw = process.stdin.isRaw;
|
savedIsRaw = process.stdin.isRaw;
|
||||||
savedSetRawMode = process.stdin.setRawMode;
|
savedSetRawMode = process.stdin.setRawMode;
|
||||||
@ -142,6 +144,13 @@ describe('readMultilineInput cursor navigation', () => {
|
|||||||
savedStdinRemoveListener = process.stdin.removeListener;
|
savedStdinRemoveListener = process.stdin.removeListener;
|
||||||
savedStdinResume = process.stdin.resume;
|
savedStdinResume = process.stdin.resume;
|
||||||
savedStdinPause = process.stdin.pause;
|
savedStdinPause = process.stdin.pause;
|
||||||
|
savedColumns = process.stdout.columns;
|
||||||
|
columnsOverridden = false;
|
||||||
|
|
||||||
|
if (termColumns !== undefined) {
|
||||||
|
Object.defineProperty(process.stdout, 'columns', { value: termColumns, configurable: true, writable: true });
|
||||||
|
columnsOverridden = true;
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
|
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
|
||||||
@ -197,6 +206,10 @@ describe('readMultilineInput cursor navigation', () => {
|
|||||||
if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener;
|
if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener;
|
||||||
if (savedStdinResume) process.stdin.resume = savedStdinResume;
|
if (savedStdinResume) process.stdin.resume = savedStdinResume;
|
||||||
if (savedStdinPause) process.stdin.pause = savedStdinPause;
|
if (savedStdinPause) process.stdin.pause = savedStdinPause;
|
||||||
|
if (columnsOverridden) {
|
||||||
|
Object.defineProperty(process.stdout, 'columns', { value: savedColumns, configurable: true, writable: true });
|
||||||
|
columnsOverridden = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -611,4 +624,338 @@ describe('readMultilineInput cursor navigation', () => {
|
|||||||
expect(result).toBe('abc\ndef\nghiX');
|
expect(result).toBe('abc\ndef\nghiX');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: arrow up within wrapped line', () => {
|
||||||
|
it('should move to previous display row within same logical line', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), first display row = 18 chars, second = 20 chars
|
||||||
|
// Type 30 chars "abcdefghijklmnopqrstuvwxyz1234" → wraps at pos 18
|
||||||
|
// Display row 1: "abcdefghijklmnopqr" (18 chars, cols 3-20 with prompt)
|
||||||
|
// Display row 2: "stuvwxyz1234" (12 chars, cols 1-12)
|
||||||
|
// Cursor at end (pos 30, display col 12), press ↑ → display col 12 in row 1 → pos 12
|
||||||
|
// Insert "X" → "abcdefghijklXmnopqrstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[AX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklXmnopqrstuvwxyz1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when on first display row of first logical line', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type "abcdefghij" (10 chars, fits in first row of 18 cols)
|
||||||
|
// Cursor at end (pos 10, first display row), press ↑ → no previous row, nothing happens
|
||||||
|
// Insert "X" → "abcdefghijX"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghij\x1B[AX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijX');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: arrow down within wrapped line', () => {
|
||||||
|
it('should move to next display row within same logical line', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
|
||||||
|
// Type 30 chars, Home → pos 0, then ↓ → display col 0 in row 2 → pos 18
|
||||||
|
// Insert "X" → "abcdefghijklmnopqrXstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[BX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing when on last display row of last logical line', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 display rows)
|
||||||
|
// Cursor at end (last display row), press ↓ → nothing happens
|
||||||
|
// Insert "X" → "abcdefghijklmnopqrstuvwxyz1234X"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[BX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: Ctrl+A moves to display row start', () => {
|
||||||
|
it('should move to display row start on wrapped second row', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
|
||||||
|
// Cursor at end (pos 30), Ctrl+A → display row start (pos 18), insert "X"
|
||||||
|
// Result: "abcdefghijklmnopqrXstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x01X\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move to display row start on first row', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
|
||||||
|
// Move cursor to middle of first display row (Home, Right*5 → pos 5)
|
||||||
|
// Ctrl+A → pos 0, insert "X"
|
||||||
|
// Result: "Xabcdefghijklmnopqrstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[C\x1B[C\x1B[C\x1B[C\x1B[C\x01X\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Xabcdefghijklmnopqrstuvwxyz1234');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: Ctrl+E moves to display row end', () => {
|
||||||
|
it('should move to display row end on first row', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
|
||||||
|
// Home → pos 0, Ctrl+E → end of first display row (pos 18), insert "X"
|
||||||
|
// Result: "abcdefghijklmnopqrXstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x05X\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrXstuvwxyz1234');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: Home moves to logical line start', () => {
|
||||||
|
it('should move from wrapped second row to logical line start', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
|
||||||
|
// Type 30 chars, cursor at end (pos 30, second display row)
|
||||||
|
// Home → logical line start (pos 0), insert "X"
|
||||||
|
// Result: "Xabcdefghijklmnopqrstuvwxyz1234"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[HX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('Xabcdefghijklmnopqrstuvwxyz1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit cursor up sequence when crossing display rows', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 rows)
|
||||||
|
// Cursor at end (second display row), Home → pos 0 (first display row)
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then: should contain \x1B[{n}A for moving up display rows
|
||||||
|
const hasUpMove = stdoutCalls.some(c => /^\x1B\[\d+A$/.test(c));
|
||||||
|
expect(hasUpMove).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: End moves to logical line end', () => {
|
||||||
|
it('should move from first display row to logical line end', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), first row = 18 chars
|
||||||
|
// Type 30 chars, Home → pos 0, End → logical line end (pos 30), insert "X"
|
||||||
|
// Result: "abcdefghijklmnopqrstuvwxyz1234X"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[FX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit cursor down sequence when crossing display rows', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars (wraps into 2 rows)
|
||||||
|
// Home → pos 0 (first display row), End → pos 30 (second display row)
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[H\x1B[F\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then: should contain \x1B[{n}B for moving down display rows
|
||||||
|
const hasDownMove = stdoutCalls.some(c => /^\x1B\[\d+B$/.test(c));
|
||||||
|
expect(hasDownMove).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stay at end when already at logical line end on last display row', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols), type 30 chars
|
||||||
|
// Cursor at end (pos 30, already at logical line end), End → nothing changes, insert "X"
|
||||||
|
// Result: "abcdefghijklmnopqrstuvwxyz1234X"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwxyz1234\x1B[FX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrstuvwxyz1234X');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: non-wrapped text retains original behavior', () => {
|
||||||
|
it('should not affect arrow up on short single-line text', async () => {
|
||||||
|
// Given: termWidth=80, short text "abc" (no wrap), ↑ does nothing
|
||||||
|
setupRawStdin([
|
||||||
|
'abc\x1B[AX\r',
|
||||||
|
], 80);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcX');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect arrow down on short single-line text', async () => {
|
||||||
|
// Given: termWidth=80, short text "abc" (no wrap), ↓ does nothing
|
||||||
|
setupRawStdin([
|
||||||
|
'abc\x1B[BX\r',
|
||||||
|
], 80);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcX');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still navigate between logical lines with arrow up', async () => {
|
||||||
|
// Given: termWidth=80, "abcde\nfgh" (no wrap), cursor at end of "fgh", ↑ → "abcde" at col 3
|
||||||
|
setupRawStdin([
|
||||||
|
'abcde\x1B[13;2ufgh\x1B[AX\r',
|
||||||
|
], 80);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcXde\nfgh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: full-width characters', () => {
|
||||||
|
it('should calculate display row boundaries with full-width chars', async () => {
|
||||||
|
// Given: termWidth=10, prompt "> " (2 cols), first row available = 8 cols
|
||||||
|
// Type "あいうえ" (4 full-width chars = 8 display cols = fills first row exactly)
|
||||||
|
// Then type "お" (2 cols, starts second row)
|
||||||
|
// Cursor at end (after "お"), Ctrl+A → display row start (pos 4, start of "お")
|
||||||
|
// Insert "X"
|
||||||
|
// Result: "あいうえXお"
|
||||||
|
setupRawStdin([
|
||||||
|
'あいうえお\x01X\r',
|
||||||
|
], 10);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('あいうえXお');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should push full-width char to next row when only 1 column remains', async () => {
|
||||||
|
// Given: termWidth=10, prompt "> " (2 cols), first row available = 8 cols
|
||||||
|
// Type "abcdefg" (7 cols) then "あ" (2 cols) → 7+2=9 > 8, "あ" goes to row 2
|
||||||
|
// Cursor at end (after "あ"), Ctrl+A → display row start at "あ" (pos 7)
|
||||||
|
// Insert "X"
|
||||||
|
// Result: "abcdefgXあ"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefgあ\x01X\r',
|
||||||
|
], 10);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefgXあ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: prompt width consideration', () => {
|
||||||
|
it('should account for prompt width in first display row', async () => {
|
||||||
|
// Given: termWidth=10, prompt "> " (2 cols), first row = 8 chars
|
||||||
|
// Type "12345678" (8 chars = fills first row) then "9" (starts row 2)
|
||||||
|
// Cursor at "9" (pos 9), ↑ → row 1 at display col 1, but only 8 chars available
|
||||||
|
// Display col 1 → pos 1
|
||||||
|
// Insert "X" → "1X234567890" ... wait, let me recalculate.
|
||||||
|
// Actually: cursor at end of "123456789" (pos 9, display col 1 in row 2)
|
||||||
|
// ↑ → display col 1 in row 1 → pos 1
|
||||||
|
// Insert "X" → "1X23456789"
|
||||||
|
setupRawStdin([
|
||||||
|
'123456789\x1B[AX\r',
|
||||||
|
], 10);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('1X23456789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add prompt offset for second logical line', async () => {
|
||||||
|
// Given: termWidth=10, prompt "> " (2 cols)
|
||||||
|
// Type "ab\n123456789" → second logical line "123456789" (9 chars, fits in 10 col row)
|
||||||
|
// Cursor at end (pos 12), ↑ → "ab" at display col 9 → clamped to col 2 → pos 2 (end of "ab")
|
||||||
|
// Insert "X" → "abX\n123456789"
|
||||||
|
setupRawStdin([
|
||||||
|
'ab\x1B[13;2u123456789\x1B[AX\r',
|
||||||
|
], 10);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abX\n123456789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('soft-wrap: cross logical line with display rows', () => {
|
||||||
|
it('should move from wrapped logical line to previous logical line last display row', async () => {
|
||||||
|
// Given: termWidth=20, prompt "> " (2 cols)
|
||||||
|
// Line 1: "abcdefghijklmnopqrstuvwx" (24 chars) → wraps: row 1 (18 chars) + row 2 (6 chars)
|
||||||
|
// Line 2: "123"
|
||||||
|
// Cursor at end of "123" (display col 3), ↑ → last display row of line 1 (row 2: "uvwx", 6 chars)
|
||||||
|
// Display col 3 → pos 21 ("v" position... let me calculate)
|
||||||
|
// Row 2 of line 1 starts at pos 18 ("stuvwx"), display col 3 → pos 21
|
||||||
|
// Insert "X" → "abcdefghijklmnopqrstuXvwx\n123"
|
||||||
|
setupRawStdin([
|
||||||
|
'abcdefghijklmnopqrstuvwx\x1B[13;2u123\x1B[AX\r',
|
||||||
|
], 20);
|
||||||
|
|
||||||
|
// When
|
||||||
|
const result = await callReadMultilineInput('> ');
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(result).toBe('abcdefghijklmnopqrstuXvwx\n123');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -250,46 +250,103 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
|||||||
|
|
||||||
// --- Buffer position helpers ---
|
// --- 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 {
|
function getLineStartAt(pos: number): number {
|
||||||
const lastNl = buffer.lastIndexOf('\n', pos - 1);
|
const lastNl = buffer.lastIndexOf('\n', pos - 1);
|
||||||
return lastNl + 1;
|
return lastNl + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLineStart(): number {
|
||||||
|
return getLineStartAt(cursorPos);
|
||||||
|
}
|
||||||
|
|
||||||
function getLineEndAt(pos: number): number {
|
function getLineEndAt(pos: number): number {
|
||||||
const nextNl = buffer.indexOf('\n', pos);
|
const nextNl = buffer.indexOf('\n', pos);
|
||||||
return nextNl >= 0 ? nextNl : buffer.length;
|
return nextNl >= 0 ? nextNl : buffer.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display width from line start to cursor */
|
function getLineEnd(): number {
|
||||||
function getDisplayColumn(): number {
|
return getLineEndAt(cursorPos);
|
||||||
return getDisplayWidth(buffer.slice(getLineStart(), cursorPos));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptWidth = getDisplayWidth(stripAnsi(prompt));
|
const promptWidth = getDisplayWidth(stripAnsi(prompt));
|
||||||
|
|
||||||
/** Terminal column (1-based) for a given buffer position */
|
// --- Display row helpers (soft-wrap awareness) ---
|
||||||
function getTerminalColumn(pos: number): number {
|
|
||||||
const lineStart = getLineStartAt(pos);
|
function getTermWidth(): number {
|
||||||
const col = getDisplayWidth(buffer.slice(lineStart, pos));
|
return process.stdout.columns || 80;
|
||||||
const isFirstLine = lineStart === 0;
|
|
||||||
return isFirstLine ? promptWidth + col + 1 : col + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find the buffer position in a line that matches a target display column */
|
/** Buffer position of the display row start that contains `pos` */
|
||||||
function findPositionByDisplayColumn(lineStart: number, lineEnd: number, targetDisplayCol: number): number {
|
function getDisplayRowStart(pos: number): number {
|
||||||
|
const logicalStart = getLineStartAt(pos);
|
||||||
|
const termWidth = getTermWidth();
|
||||||
|
const isFirstLogicalLine = logicalStart === 0;
|
||||||
|
let firstRowWidth = isFirstLogicalLine ? termWidth - promptWidth : termWidth;
|
||||||
|
if (firstRowWidth <= 0) firstRowWidth = 1;
|
||||||
|
|
||||||
|
let rowStart = logicalStart;
|
||||||
|
let accumulated = 0;
|
||||||
|
let available = firstRowWidth;
|
||||||
|
let i = logicalStart;
|
||||||
|
for (const ch of buffer.slice(logicalStart, pos)) {
|
||||||
|
const w = getDisplayWidth(ch);
|
||||||
|
if (accumulated + w > available) {
|
||||||
|
rowStart = i;
|
||||||
|
accumulated = w;
|
||||||
|
available = termWidth;
|
||||||
|
} else {
|
||||||
|
accumulated += w;
|
||||||
|
// Row exactly filled — next position starts a new display row
|
||||||
|
if (accumulated === available) {
|
||||||
|
rowStart = i + ch.length;
|
||||||
|
accumulated = 0;
|
||||||
|
available = termWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += ch.length;
|
||||||
|
}
|
||||||
|
return rowStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Buffer position of the display row end that contains `pos` */
|
||||||
|
function getDisplayRowEnd(pos: number): number {
|
||||||
|
const logicalEnd = getLineEndAt(pos);
|
||||||
|
const rowStart = getDisplayRowStart(pos);
|
||||||
|
const termWidth = getTermWidth();
|
||||||
|
// The first display row of the first logical line has reduced width
|
||||||
|
const isFirstDisplayRow = rowStart === 0;
|
||||||
|
const available = isFirstDisplayRow ? termWidth - promptWidth : termWidth;
|
||||||
|
|
||||||
|
let accumulated = 0;
|
||||||
|
let i = rowStart;
|
||||||
|
for (const ch of buffer.slice(rowStart, logicalEnd)) {
|
||||||
|
const w = getDisplayWidth(ch);
|
||||||
|
if (accumulated + w > available) return i;
|
||||||
|
accumulated += w;
|
||||||
|
i += ch.length;
|
||||||
|
}
|
||||||
|
return logicalEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display column (0-based) within the display row that contains `pos` */
|
||||||
|
function getDisplayRowColumn(pos: number): number {
|
||||||
|
return getDisplayWidth(buffer.slice(getDisplayRowStart(pos), pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal column (1-based) for a given buffer position */
|
||||||
|
function getTerminalColumn(pos: number): number {
|
||||||
|
const displayRowStart = getDisplayRowStart(pos);
|
||||||
|
const col = getDisplayWidth(buffer.slice(displayRowStart, pos));
|
||||||
|
// Only the first display row of the first logical line has the prompt offset
|
||||||
|
const isFirstDisplayRow = displayRowStart === 0;
|
||||||
|
return isFirstDisplayRow ? promptWidth + col + 1 : col + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the buffer position in a range that matches a target display column */
|
||||||
|
function findPositionByDisplayColumn(rangeStart: number, rangeEnd: number, targetDisplayCol: number): number {
|
||||||
let displayCol = 0;
|
let displayCol = 0;
|
||||||
let pos = lineStart;
|
let pos = rangeStart;
|
||||||
for (const ch of buffer.slice(lineStart, lineEnd)) {
|
for (const ch of buffer.slice(rangeStart, rangeEnd)) {
|
||||||
const w = getDisplayWidth(ch);
|
const w = getDisplayWidth(ch);
|
||||||
if (displayCol + w > targetDisplayCol) break;
|
if (displayCol + w > targetDisplayCol) break;
|
||||||
displayCol += w;
|
displayCol += w;
|
||||||
@ -322,23 +379,77 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
|||||||
|
|
||||||
// --- Cursor movement ---
|
// --- Cursor movement ---
|
||||||
|
|
||||||
function moveCursorToLineStart(): void {
|
function moveCursorToDisplayRowStart(): void {
|
||||||
const displayOffset = getDisplayColumn();
|
const displayRowStart = getDisplayRowStart(cursorPos);
|
||||||
|
const displayOffset = getDisplayRowColumn(cursorPos);
|
||||||
if (displayOffset > 0) {
|
if (displayOffset > 0) {
|
||||||
cursorPos = getLineStart();
|
cursorPos = displayRowStart;
|
||||||
process.stdout.write(`\x1B[${displayOffset}D`);
|
process.stdout.write(`\x1B[${displayOffset}D`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveCursorToLineEnd(): void {
|
function moveCursorToDisplayRowEnd(): void {
|
||||||
const lineEnd = getLineEnd();
|
const displayRowEnd = getDisplayRowEnd(cursorPos);
|
||||||
const displayOffset = getDisplayWidth(buffer.slice(cursorPos, lineEnd));
|
const displayOffset = getDisplayWidth(buffer.slice(cursorPos, displayRowEnd));
|
||||||
if (displayOffset > 0) {
|
if (displayOffset > 0) {
|
||||||
cursorPos = lineEnd;
|
cursorPos = displayRowEnd;
|
||||||
process.stdout.write(`\x1B[${displayOffset}C`);
|
process.stdout.write(`\x1B[${displayOffset}C`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move cursor to a target display row, positioning at the given display column */
|
||||||
|
function moveCursorToDisplayRow(
|
||||||
|
targetRowStart: number,
|
||||||
|
targetRowEnd: number,
|
||||||
|
displayCol: number,
|
||||||
|
direction: 'A' | 'B',
|
||||||
|
): void {
|
||||||
|
cursorPos = findPositionByDisplayColumn(targetRowStart, targetRowEnd, displayCol);
|
||||||
|
const termCol = getTerminalColumn(cursorPos);
|
||||||
|
process.stdout.write(`\x1B[${direction}`);
|
||||||
|
process.stdout.write(`\x1B[${termCol}G`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count how many display rows lie between two buffer positions in the same logical line */
|
||||||
|
function countDisplayRowsBetween(from: number, to: number): number {
|
||||||
|
if (from === to) return 0;
|
||||||
|
const start = Math.min(from, to);
|
||||||
|
const end = Math.max(from, to);
|
||||||
|
let count = 0;
|
||||||
|
let pos = start;
|
||||||
|
while (pos < end) {
|
||||||
|
const nextRowStart = getDisplayRowEnd(pos);
|
||||||
|
if (nextRowStart >= end) break;
|
||||||
|
pos = nextRowStart;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorToLogicalLineStart(): void {
|
||||||
|
const lineStart = getLineStart();
|
||||||
|
if (cursorPos === lineStart) return;
|
||||||
|
const rowDiff = countDisplayRowsBetween(lineStart, cursorPos);
|
||||||
|
cursorPos = lineStart;
|
||||||
|
if (rowDiff > 0) {
|
||||||
|
process.stdout.write(`\x1B[${rowDiff}A`);
|
||||||
|
}
|
||||||
|
const termCol = getTerminalColumn(cursorPos);
|
||||||
|
process.stdout.write(`\x1B[${termCol}G`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCursorToLogicalLineEnd(): void {
|
||||||
|
const lineEnd = getLineEnd();
|
||||||
|
if (cursorPos === lineEnd) return;
|
||||||
|
const rowDiff = countDisplayRowsBetween(cursorPos, lineEnd);
|
||||||
|
cursorPos = lineEnd;
|
||||||
|
if (rowDiff > 0) {
|
||||||
|
process.stdout.write(`\x1B[${rowDiff}B`);
|
||||||
|
}
|
||||||
|
const termCol = getTerminalColumn(cursorPos);
|
||||||
|
process.stdout.write(`\x1B[${termCol}G`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Buffer editing ---
|
// --- Buffer editing ---
|
||||||
|
|
||||||
function insertAt(pos: number, text: string): void {
|
function insertAt(pos: number, text: string): void {
|
||||||
@ -461,27 +572,40 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
|||||||
},
|
},
|
||||||
onArrowUp() {
|
onArrowUp() {
|
||||||
if (state !== 'normal') return;
|
if (state !== 'normal') return;
|
||||||
const lineStart = getLineStart();
|
const logicalLineStart = getLineStart();
|
||||||
if (lineStart === 0) return;
|
const displayRowStart = getDisplayRowStart(cursorPos);
|
||||||
const displayCol = getDisplayColumn();
|
const displayCol = getDisplayRowColumn(cursorPos);
|
||||||
const prevLineStart = getLineStartAt(lineStart - 1);
|
|
||||||
const prevLineEnd = lineStart - 1;
|
if (displayRowStart > logicalLineStart) {
|
||||||
cursorPos = findPositionByDisplayColumn(prevLineStart, prevLineEnd, displayCol);
|
// Move to previous display row within the same logical line
|
||||||
const termCol = getTerminalColumn(cursorPos);
|
const prevRowStart = getDisplayRowStart(displayRowStart - 1);
|
||||||
process.stdout.write('\x1B[A');
|
const prevRowEnd = getDisplayRowEnd(displayRowStart - 1);
|
||||||
process.stdout.write(`\x1B[${termCol}G`);
|
moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A');
|
||||||
|
} else if (logicalLineStart > 0) {
|
||||||
|
// Move to the last display row of the previous logical line
|
||||||
|
const prevLogicalLineEnd = logicalLineStart - 1;
|
||||||
|
const prevRowStart = getDisplayRowStart(prevLogicalLineEnd);
|
||||||
|
const prevRowEnd = getDisplayRowEnd(prevLogicalLineEnd);
|
||||||
|
moveCursorToDisplayRow(prevRowStart, prevRowEnd, displayCol, 'A');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onArrowDown() {
|
onArrowDown() {
|
||||||
if (state !== 'normal') return;
|
if (state !== 'normal') return;
|
||||||
const lineEnd = getLineEnd();
|
const logicalLineEnd = getLineEnd();
|
||||||
if (lineEnd >= buffer.length) return;
|
const displayRowEnd = getDisplayRowEnd(cursorPos);
|
||||||
const displayCol = getDisplayColumn();
|
const displayCol = getDisplayRowColumn(cursorPos);
|
||||||
const nextLineStart = lineEnd + 1;
|
|
||||||
const nextLineEnd = getLineEndAt(nextLineStart);
|
if (displayRowEnd < logicalLineEnd) {
|
||||||
cursorPos = findPositionByDisplayColumn(nextLineStart, nextLineEnd, displayCol);
|
// Move to next display row within the same logical line
|
||||||
const termCol = getTerminalColumn(cursorPos);
|
const nextRowStart = displayRowEnd;
|
||||||
process.stdout.write('\x1B[B');
|
const nextRowEnd = getDisplayRowEnd(displayRowEnd);
|
||||||
process.stdout.write(`\x1B[${termCol}G`);
|
moveCursorToDisplayRow(nextRowStart, nextRowEnd, displayCol, 'B');
|
||||||
|
} else if (logicalLineEnd < buffer.length) {
|
||||||
|
// Move to the first display row of the next logical line
|
||||||
|
const nextLineStart = logicalLineEnd + 1;
|
||||||
|
const nextRowEnd = getDisplayRowEnd(nextLineStart);
|
||||||
|
moveCursorToDisplayRow(nextLineStart, nextRowEnd, displayCol, 'B');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onWordLeft() {
|
onWordLeft() {
|
||||||
if (state !== 'normal') return;
|
if (state !== 'normal') return;
|
||||||
@ -507,11 +631,11 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
|||||||
},
|
},
|
||||||
onHome() {
|
onHome() {
|
||||||
if (state !== 'normal') return;
|
if (state !== 'normal') return;
|
||||||
moveCursorToLineStart();
|
moveCursorToLogicalLineStart();
|
||||||
},
|
},
|
||||||
onEnd() {
|
onEnd() {
|
||||||
if (state !== 'normal') return;
|
if (state !== 'normal') return;
|
||||||
moveCursorToLineEnd();
|
moveCursorToLogicalLineEnd();
|
||||||
},
|
},
|
||||||
onChar(ch: string) {
|
onChar(ch: string) {
|
||||||
if (state === 'paste') {
|
if (state === 'paste') {
|
||||||
@ -543,8 +667,8 @@ export function readMultilineInput(prompt: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
// Editing
|
// Editing
|
||||||
if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; }
|
if (ch === '\x7F' || ch === '\x08') { deleteCharBefore(); return; }
|
||||||
if (ch === '\x01') { moveCursorToLineStart(); return; }
|
if (ch === '\x01') { moveCursorToDisplayRowStart(); return; }
|
||||||
if (ch === '\x05') { moveCursorToLineEnd(); return; }
|
if (ch === '\x05') { moveCursorToDisplayRowEnd(); return; }
|
||||||
if (ch === '\x0B') { deleteToLineEnd(); return; }
|
if (ch === '\x0B') { deleteToLineEnd(); return; }
|
||||||
if (ch === '\x15') { deleteToLineStart(); return; }
|
if (ch === '\x15') { deleteToLineStart(); return; }
|
||||||
if (ch === '\x17') { deleteWord(); return; }
|
if (ch === '\x17') { deleteWord(); return; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user