chore: multiline traversal support (#51)

Co-authored-by: Shubham Naik <shub@memgpt.ai>
This commit is contained in:
Shubham Naik
2025-11-01 10:00:04 -07:00
committed by GitHub
parent 4118d018fe
commit 14e67fa156
7 changed files with 139 additions and 42 deletions

View File

@@ -6,7 +6,7 @@ import { Box, Text, useApp, useInput } from "ink";
import { useState } from "react"; import { useState } from "react";
import { asciiLogo } from "../cli/components/AsciiArt.ts"; import { asciiLogo } from "../cli/components/AsciiArt.ts";
import { settingsManager } from "../settings-manager"; 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"; type SetupMode = "menu" | "device-code" | "auth-code" | "self-host" | "done";

View File

@@ -60,6 +60,28 @@ export function Input({
const [historyIndex, setHistoryIndex] = useState(-1); const [historyIndex, setHistoryIndex] = useState(-1);
const [temporaryInput, setTemporaryInput] = useState(""); 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) // Sync with external mode changes (from plan approval dialog)
useEffect(() => { useEffect(() => {
if (externalMode !== undefined) { if (externalMode !== undefined) {
@@ -145,17 +167,58 @@ 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) => { useInput((_input, key) => {
// Don't interfere with autocomplete navigation // Don't interfere with autocomplete navigation
if (isAutocompleteActive) { if (isAutocompleteActive) {
return; return;
} }
if (key.upArrow || key.downArrow) {
// Calculate which wrapped line the cursor is on
const lineWidth = contentWidth; // Available width for text
// Calculate current wrapped line number and position within that line
const currentWrappedLine = Math.floor(currentCursorPosition / lineWidth);
const columnInCurrentLine = currentCursorPosition % lineWidth;
// Calculate total number of wrapped lines
const totalWrappedLines = Math.ceil(value.length / lineWidth) || 1;
if (key.upArrow) { if (key.upArrow) {
// Navigate backwards in history 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; if (history.length === 0) return;
setAtStartBoundary(false); // Reset for next time
if (historyIndex === -1) { if (historyIndex === -1) {
// Starting to navigate history - save current input // Starting to navigate history - save current input
setTemporaryInput(value); setTemporaryInput(value);
@@ -168,7 +231,37 @@ export function Input({
setValue(history[historyIndex - 1] ?? ""); setValue(history[historyIndex - 1] ?? "");
} }
} else if (key.downArrow) { } else if (key.downArrow) {
// Navigate forwards in history 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 === -1) return; // Not in history mode
if (historyIndex < history.length - 1) { if (historyIndex < history.length - 1) {
@@ -181,6 +274,7 @@ export function Input({
setValue(temporaryInput); setValue(temporaryInput);
} }
} }
}
}); });
// Reset escape and ctrl-c state when user types (value changes) // Reset escape and ctrl-c state when user types (value changes)
@@ -191,6 +285,11 @@ export function Input({
setCtrlCPressed(false); setCtrlCPressed(false);
if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); 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; previousValueRef.current = value;
}, [value]); }, [value]);
@@ -282,9 +381,6 @@ export function Input({
setValue(newValue); setValue(newValue);
setCursorPos(newCursorPos); 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 // Get display name and color for permission mode

View File

@@ -54,11 +54,6 @@ export function PasteAwareTextInput({
} }
}, [cursorPosition]); }, [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<{ const TextInputAny = RawTextInput as unknown as React.ComponentType<{
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
@@ -262,6 +257,7 @@ export function PasteAwareTextInput({
externalCursorOffset={nudgeCursorOffset} externalCursorOffset={nudgeCursorOffset}
onCursorOffsetChange={(n: number) => { onCursorOffsetChange={(n: number) => {
caretOffsetRef.current = n; caretOffsetRef.current = n;
onCursorMove?.(n);
}} }}
onChange={handleChange} onChange={handleChange}
onSubmit={handleSubmit} onSubmit={handleSubmit}

View File

@@ -266,7 +266,7 @@ describe("tool truncation integration tests", () => {
const message = startResult.content[0]?.text || ""; const message = startResult.content[0]?.text || "";
const bashIdMatch = message.match(/with ID: (.+)/); const bashIdMatch = message.match(/with ID: (.+)/);
expect(bashIdMatch).toBeTruthy(); expect(bashIdMatch).toBeTruthy();
const bashId = bashIdMatch![1]; const bashId = bashIdMatch?.[1];
// Wait a bit for output to accumulate // Wait a bit for output to accumulate
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));

View File

@@ -70,7 +70,7 @@ describe("truncation utilities", () => {
}); });
test("truncates long lines when maxCharsPerLine specified", () => { 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"); const result = truncateByLines(text, 10, 500, "Test");
expect(result.wasTruncated).toBe(true); expect(result.wasTruncated).toBe(true);

View File

@@ -25,7 +25,7 @@ export const LIMITS = {
export function truncateByChars( export function truncateByChars(
text: string, text: string,
maxChars: number, maxChars: number,
toolName: string = "output", _toolName: string = "output",
): { content: string; wasTruncated: boolean } { ): { content: string; wasTruncated: boolean } {
if (text.length <= maxChars) { if (text.length <= maxChars) {
return { content: text, wasTruncated: false }; return { content: text, wasTruncated: false };
@@ -48,7 +48,7 @@ export function truncateByLines(
text: string, text: string,
maxLines: number, maxLines: number,
maxCharsPerLine?: number, maxCharsPerLine?: number,
toolName: string = "output", _toolName: string = "output",
): { ): {
content: string; content: string;
wasTruncated: boolean; wasTruncated: boolean;
@@ -66,7 +66,7 @@ export function truncateByLines(
selectedLines = selectedLines.map((line) => { selectedLines = selectedLines.map((line) => {
if (line.length > maxCharsPerLine) { if (line.length > maxCharsPerLine) {
linesWereTruncatedInLength = true; linesWereTruncatedInLength = true;
return line.slice(0, maxCharsPerLine) + "... [line truncated]"; return `${line.slice(0, maxCharsPerLine)}... [line truncated]`;
} }
return line; return line;
}); });
@@ -90,7 +90,7 @@ export function truncateByLines(
); );
} }
content += "\n\n" + notices.join(" "); content += `\n\n${notices.join(" ")}`;
} }
return { return {

View File

@@ -46,7 +46,7 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
return; return;
} }
// Treat Escape as a control key (don't insert into value) // 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; return;
} }
if (key.return) { if (key.return) {
@@ -68,6 +68,11 @@ function TextInput({ value: originalValue, placeholder = '', focus = true, mask,
nextCursorOffset++; 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) { else if (key.backspace || key.delete) {
if (cursorOffset > 0) { if (cursorOffset > 0) {
nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length); nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length);