From 14e67fa156da3ddbf5c60f01dd67ba3e5d091f39 Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Sat, 1 Nov 2025 10:00:04 -0700 Subject: [PATCH] chore: multiline traversal support (#51) Co-authored-by: Shubham Naik --- src/auth/setup-ui.tsx | 2 +- src/cli/components/InputRich.tsx | 154 +++++++++++++++++---- src/cli/components/PasteAwareTextInput.tsx | 6 +- src/tests/tools/tool-truncation.test.ts | 2 +- src/tests/tools/truncation.test.ts | 2 +- src/tools/impl/truncation.ts | 8 +- vendor/ink-text-input/build/index.js | 7 +- 7 files changed, 139 insertions(+), 42 deletions(-) diff --git a/src/auth/setup-ui.tsx b/src/auth/setup-ui.tsx index a89ce42..d0f77cf 100644 --- a/src/auth/setup-ui.tsx +++ b/src/auth/setup-ui.tsx @@ -6,7 +6,7 @@ import { Box, Text, useApp, useInput } from "ink"; import { useState } from "react"; import { asciiLogo } from "../cli/components/AsciiArt.ts"; import { settingsManager } from "../settings-manager"; -import { OAUTH_CONFIG, pollForToken, requestDeviceCode } from "./oauth"; +import { pollForToken, requestDeviceCode } from "./oauth"; type SetupMode = "menu" | "device-code" | "auth-code" | "self-host" | "done"; diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index c983653..197fba7 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -60,6 +60,28 @@ export function Input({ const [historyIndex, setHistoryIndex] = useState(-1); const [temporaryInput, setTemporaryInput] = useState(""); + // Track if we just moved to a boundary (for two-step history navigation) + const [atStartBoundary, setAtStartBoundary] = useState(false); + const [atEndBoundary, setAtEndBoundary] = useState(false); + + // Reset cursor position after it's been applied + useEffect(() => { + if (cursorPos !== undefined) { + const timer = setTimeout(() => setCursorPos(undefined), 0); + return () => clearTimeout(timer); + } + }, [cursorPos]); + + // Reset boundary flags when cursor moves (via left/right arrows) + useEffect(() => { + if (currentCursorPosition !== 0) { + setAtStartBoundary(false); + } + if (currentCursorPosition !== value.length) { + setAtEndBoundary(false); + } + }, [currentCursorPosition, value.length]); + // Sync with external mode changes (from plan approval dialog) useEffect(() => { if (externalMode !== undefined) { @@ -145,40 +167,112 @@ export function Input({ } }); - // Handle up/down arrow keys for command history + // Handle up/down arrow keys for wrapped text navigation and command history useInput((_input, key) => { // Don't interfere with autocomplete navigation if (isAutocompleteActive) { return; } - if (key.upArrow) { - // Navigate backwards in history - if (history.length === 0) return; + if (key.upArrow || key.downArrow) { + // Calculate which wrapped line the cursor is on + const lineWidth = contentWidth; // Available width for text - if (historyIndex === -1) { - // Starting to navigate history - save current input - setTemporaryInput(value); - // Go to most recent command - setHistoryIndex(history.length - 1); - setValue(history[history.length - 1] ?? ""); - } else if (historyIndex > 0) { - // Go to older command - setHistoryIndex(historyIndex - 1); - setValue(history[historyIndex - 1] ?? ""); - } - } else if (key.downArrow) { - // Navigate forwards in history - if (historyIndex === -1) return; // Not in history mode + // Calculate current wrapped line number and position within that line + const currentWrappedLine = Math.floor(currentCursorPosition / lineWidth); + const columnInCurrentLine = currentCursorPosition % lineWidth; - if (historyIndex < history.length - 1) { - // Go to newer command - setHistoryIndex(historyIndex + 1); - setValue(history[historyIndex + 1] ?? ""); - } else { - // At the end of history - restore temporary input - setHistoryIndex(-1); - setValue(temporaryInput); + // Calculate total number of wrapped lines + const totalWrappedLines = Math.ceil(value.length / lineWidth) || 1; + + if (key.upArrow) { + if (currentWrappedLine > 0) { + // Not on first wrapped line - move cursor up one wrapped line + // Try to maintain the same column position + const targetLine = currentWrappedLine - 1; + const targetLineStart = targetLine * lineWidth; + const targetLineEnd = Math.min( + targetLineStart + lineWidth, + value.length, + ); + const targetLineLength = targetLineEnd - targetLineStart; + + // Move to same column in previous line, or end of line if shorter + const newPosition = + targetLineStart + Math.min(columnInCurrentLine, targetLineLength); + setCursorPos(newPosition); + setAtStartBoundary(false); // Reset boundary flag + return; // Don't trigger history + } + + // On first wrapped line + // First press: move to start, second press: navigate history + if (currentCursorPosition > 0 && !atStartBoundary) { + // First press - move cursor to start + setCursorPos(0); + setAtStartBoundary(true); + return; + } + + // Second press or already at start - trigger history navigation + if (history.length === 0) return; + + setAtStartBoundary(false); // Reset for next time + + if (historyIndex === -1) { + // Starting to navigate history - save current input + setTemporaryInput(value); + // Go to most recent command + setHistoryIndex(history.length - 1); + setValue(history[history.length - 1] ?? ""); + } else if (historyIndex > 0) { + // Go to older command + setHistoryIndex(historyIndex - 1); + setValue(history[historyIndex - 1] ?? ""); + } + } else if (key.downArrow) { + if (currentWrappedLine < totalWrappedLines - 1) { + // Not on last wrapped line - move cursor down one wrapped line + // Try to maintain the same column position + const targetLine = currentWrappedLine + 1; + const targetLineStart = targetLine * lineWidth; + const targetLineEnd = Math.min( + targetLineStart + lineWidth, + value.length, + ); + const targetLineLength = targetLineEnd - targetLineStart; + + // Move to same column in next line, or end of line if shorter + const newPosition = + targetLineStart + Math.min(columnInCurrentLine, targetLineLength); + setCursorPos(newPosition); + setAtEndBoundary(false); // Reset boundary flag + return; // Don't trigger history + } + + // On last wrapped line + // First press: move to end, second press: navigate history + if (currentCursorPosition < value.length && !atEndBoundary) { + // First press - move cursor to end + setCursorPos(value.length); + setAtEndBoundary(true); + return; + } + + // Second press or already at end - trigger history navigation + setAtEndBoundary(false); // Reset for next time + + if (historyIndex === -1) return; // Not in history mode + + if (historyIndex < history.length - 1) { + // Go to newer command + setHistoryIndex(historyIndex + 1); + setValue(history[historyIndex + 1] ?? ""); + } else { + // At the end of history - restore temporary input + setHistoryIndex(-1); + setValue(temporaryInput); + } } } }); @@ -191,6 +285,11 @@ export function Input({ setCtrlCPressed(false); if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); } + // Reset boundary flags when value changes (user is typing) + if (value !== previousValueRef.current) { + setAtStartBoundary(false); + setAtEndBoundary(false); + } previousValueRef.current = value; }, [value]); @@ -282,9 +381,6 @@ export function Input({ setValue(newValue); setCursorPos(newCursorPos); - - // Reset cursor position after a short delay so it only applies once - setTimeout(() => setCursorPos(undefined), 50); }; // Get display name and color for permission mode diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index e46a93a..ac9f979 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -54,11 +54,6 @@ export function PasteAwareTextInput({ } }, [cursorPosition]); - // Notify parent of cursor position changes - // Default assumption: cursor is at the end when typing - useEffect(() => { - onCursorMove?.(displayValue.length); - }, [displayValue, onCursorMove]); const TextInputAny = RawTextInput as unknown as React.ComponentType<{ value: string; onChange: (value: string) => void; @@ -262,6 +257,7 @@ export function PasteAwareTextInput({ externalCursorOffset={nudgeCursorOffset} onCursorOffsetChange={(n: number) => { caretOffsetRef.current = n; + onCursorMove?.(n); }} onChange={handleChange} onSubmit={handleSubmit} diff --git a/src/tests/tools/tool-truncation.test.ts b/src/tests/tools/tool-truncation.test.ts index 89d81f0..7c8504b 100644 --- a/src/tests/tools/tool-truncation.test.ts +++ b/src/tests/tools/tool-truncation.test.ts @@ -266,7 +266,7 @@ describe("tool truncation integration tests", () => { const message = startResult.content[0]?.text || ""; const bashIdMatch = message.match(/with ID: (.+)/); expect(bashIdMatch).toBeTruthy(); - const bashId = bashIdMatch![1]; + const bashId = bashIdMatch?.[1]; // Wait a bit for output to accumulate await new Promise((resolve) => setTimeout(resolve, 100)); diff --git a/src/tests/tools/truncation.test.ts b/src/tests/tools/truncation.test.ts index 086f1e0..44ee4e5 100644 --- a/src/tests/tools/truncation.test.ts +++ b/src/tests/tools/truncation.test.ts @@ -70,7 +70,7 @@ describe("truncation utilities", () => { }); test("truncates long lines when maxCharsPerLine specified", () => { - const text = "short\n" + "a".repeat(1000) + "\nshort"; + const text = `short\n${"a".repeat(1000)}\nshort`; const result = truncateByLines(text, 10, 500, "Test"); expect(result.wasTruncated).toBe(true); diff --git a/src/tools/impl/truncation.ts b/src/tools/impl/truncation.ts index 34df044..2b25399 100644 --- a/src/tools/impl/truncation.ts +++ b/src/tools/impl/truncation.ts @@ -25,7 +25,7 @@ export const LIMITS = { export function truncateByChars( text: string, maxChars: number, - toolName: string = "output", + _toolName: string = "output", ): { content: string; wasTruncated: boolean } { if (text.length <= maxChars) { return { content: text, wasTruncated: false }; @@ -48,7 +48,7 @@ export function truncateByLines( text: string, maxLines: number, maxCharsPerLine?: number, - toolName: string = "output", + _toolName: string = "output", ): { content: string; wasTruncated: boolean; @@ -66,7 +66,7 @@ export function truncateByLines( selectedLines = selectedLines.map((line) => { if (line.length > maxCharsPerLine) { linesWereTruncatedInLength = true; - return line.slice(0, maxCharsPerLine) + "... [line truncated]"; + return `${line.slice(0, maxCharsPerLine)}... [line truncated]`; } return line; }); @@ -90,7 +90,7 @@ export function truncateByLines( ); } - content += "\n\n" + notices.join(" "); + content += `\n\n${notices.join(" ")}`; } return { diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js index 05197c9..3ac2210 100644 --- a/vendor/ink-text-input/build/index.js +++ b/vendor/ink-text-input/build/index.js @@ -46,7 +46,7 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask, return; } // Treat Escape as a control key (don't insert into value) - if (key.escape || key.upArrow || key.downArrow || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { + if (key.escape || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { return; } if (key.return) { @@ -68,6 +68,11 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask, nextCursorOffset++; } } + else if (key.upArrow || key.downArrow) { + // Handle wrapped line navigation - don't handle here, let parent decide + // Parent will check cursor position to determine if at boundary + return; + } else if (key.backspace || key.delete) { if (cursorOffset > 0) { nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length);