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 { 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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,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) => {
|
useInput((_input, key) => {
|
||||||
// Don't interfere with autocomplete navigation
|
// Don't interfere with autocomplete navigation
|
||||||
if (isAutocompleteActive) {
|
if (isAutocompleteActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.upArrow) {
|
if (key.upArrow || key.downArrow) {
|
||||||
// Navigate backwards in history
|
// Calculate which wrapped line the cursor is on
|
||||||
if (history.length === 0) return;
|
const lineWidth = contentWidth; // Available width for text
|
||||||
|
|
||||||
if (historyIndex === -1) {
|
// Calculate current wrapped line number and position within that line
|
||||||
// Starting to navigate history - save current input
|
const currentWrappedLine = Math.floor(currentCursorPosition / lineWidth);
|
||||||
setTemporaryInput(value);
|
const columnInCurrentLine = currentCursorPosition % lineWidth;
|
||||||
// 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
|
|
||||||
|
|
||||||
if (historyIndex < history.length - 1) {
|
// Calculate total number of wrapped lines
|
||||||
// Go to newer command
|
const totalWrappedLines = Math.ceil(value.length / lineWidth) || 1;
|
||||||
setHistoryIndex(historyIndex + 1);
|
|
||||||
setValue(history[historyIndex + 1] ?? "");
|
if (key.upArrow) {
|
||||||
} else {
|
if (currentWrappedLine > 0) {
|
||||||
// At the end of history - restore temporary input
|
// Not on first wrapped line - move cursor up one wrapped line
|
||||||
setHistoryIndex(-1);
|
// Try to maintain the same column position
|
||||||
setValue(temporaryInput);
|
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);
|
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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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;
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user