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:
nrs 2026-02-09 00:18:07 +09:00 committed by GitHub
parent cdedb4326e
commit c542dc0896
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 524 additions and 53 deletions

View File

@ -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');
});
});
}); });

View File

@ -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; }