chore: multiline traversal support (#51)
Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
7
vendor/ink-text-input/build/index.js
vendored
7
vendor/ink-text-input/build/index.js
vendored
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user