diff --git a/apps/cli/src/__tests__/MultilineTextInput.test.ts b/apps/cli/src/__tests__/MultilineTextInput.test.ts new file mode 100644 index 0000000000..bc75438aa0 --- /dev/null +++ b/apps/cli/src/__tests__/MultilineTextInput.test.ts @@ -0,0 +1,343 @@ +/** + * Tests for MultilineTextInput component + */ + +describe("MultilineTextInput", () => { + describe("cursor position calculations", () => { + // Test the getCursorPosition logic + const getCursorPosition = (value: string, cursorIndex: number): { line: number; col: number } => { + const lines = value.split("\n") + let pos = 0 + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const lineEnd = pos + line.length + if (cursorIndex <= lineEnd) { + return { line: i, col: cursorIndex - pos } + } + pos = lineEnd + 1 // +1 for newline + } + // Cursor at very end + return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length } + } + + // Test the getIndexFromPosition logic + const getIndexFromPosition = (value: string, line: number, col: number): number => { + const lines = value.split("\n") + let index = 0 + for (let i = 0; i < line && i < lines.length; i++) { + index += lines[i]!.length + 1 // +1 for newline + } + const targetLine = lines[line] || "" + index += Math.min(col, targetLine.length) + return index + } + + it("should calculate cursor position for single line", () => { + const value = "hello" + expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) + expect(getCursorPosition(value, 2)).toEqual({ line: 0, col: 2 }) + expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) + }) + + it("should calculate cursor position for multiple lines", () => { + const value = "hello\nworld" + // "hello" is 5 chars, newline at index 5 + // "world" starts at index 6 + expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) + expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) // End of first line + expect(getCursorPosition(value, 6)).toEqual({ line: 1, col: 0 }) // Start of second line + expect(getCursorPosition(value, 8)).toEqual({ line: 1, col: 2 }) // Middle of second line + expect(getCursorPosition(value, 11)).toEqual({ line: 1, col: 5 }) // End of second line + }) + + it("should calculate cursor position for three lines", () => { + const value = "foo\nbar\nbaz" + // "foo" = 3 chars, newline at 3 + // "bar" starts at 4, ends at 6, newline at 7 + // "baz" starts at 8 + expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 }) + expect(getCursorPosition(value, 3)).toEqual({ line: 0, col: 3 }) + expect(getCursorPosition(value, 4)).toEqual({ line: 1, col: 0 }) + expect(getCursorPosition(value, 7)).toEqual({ line: 1, col: 3 }) + expect(getCursorPosition(value, 8)).toEqual({ line: 2, col: 0 }) + expect(getCursorPosition(value, 11)).toEqual({ line: 2, col: 3 }) + }) + + it("should calculate index from position for single line", () => { + const value = "hello" + expect(getIndexFromPosition(value, 0, 0)).toBe(0) + expect(getIndexFromPosition(value, 0, 2)).toBe(2) + expect(getIndexFromPosition(value, 0, 5)).toBe(5) + }) + + it("should calculate index from position for multiple lines", () => { + const value = "hello\nworld" + expect(getIndexFromPosition(value, 0, 0)).toBe(0) + expect(getIndexFromPosition(value, 0, 5)).toBe(5) + expect(getIndexFromPosition(value, 1, 0)).toBe(6) + expect(getIndexFromPosition(value, 1, 2)).toBe(8) + expect(getIndexFromPosition(value, 1, 5)).toBe(11) + }) + + it("should clamp column to line length", () => { + const value = "hi\nworld" + // First line "hi" is only 2 chars, requesting col 5 should clamp to 2 + expect(getIndexFromPosition(value, 0, 5)).toBe(2) + }) + }) + + describe("line splitting", () => { + it("should split empty string into single empty line", () => { + const value = "" + const lines = value.split("\n") + expect(lines).toEqual([""]) + }) + + it("should split single line correctly", () => { + const value = "hello world" + const lines = value.split("\n") + expect(lines).toEqual(["hello world"]) + }) + + it("should split multiple lines correctly", () => { + const value = "foo\nbar\nbaz" + const lines = value.split("\n") + expect(lines).toEqual(["foo", "bar", "baz"]) + }) + + it("should handle trailing newline", () => { + const value = "foo\nbar\n" + const lines = value.split("\n") + expect(lines).toEqual(["foo", "bar", ""]) + }) + + it("should handle empty lines in middle", () => { + const value = "foo\n\nbaz" + const lines = value.split("\n") + expect(lines).toEqual(["foo", "", "baz"]) + }) + }) + + describe("line normalization", () => { + const normalizeLineEndings = (text: string): string => { + if (text == null) return "" + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + } + + it("should normalize CRLF to LF", () => { + expect(normalizeLineEndings("hello\r\nworld")).toBe("hello\nworld") + }) + + it("should normalize CR to LF", () => { + expect(normalizeLineEndings("hello\rworld")).toBe("hello\nworld") + }) + + it("should leave LF unchanged", () => { + expect(normalizeLineEndings("hello\nworld")).toBe("hello\nworld") + }) + + it("should handle null/undefined", () => { + expect(normalizeLineEndings(null as unknown as string)).toBe("") + expect(normalizeLineEndings(undefined as unknown as string)).toBe("") + }) + + it("should handle mixed line endings", () => { + expect(normalizeLineEndings("a\r\nb\rc\nd")).toBe("a\nb\nc\nd") + }) + }) + + describe("key binding behavior", () => { + it("should detect Ctrl+Enter for newline insertion", () => { + const isNewlineKey = (key: { return: boolean; ctrl: boolean }) => key.return && key.ctrl + expect(isNewlineKey({ return: true, ctrl: true })).toBe(true) + expect(isNewlineKey({ return: true, ctrl: false })).toBe(false) + expect(isNewlineKey({ return: false, ctrl: true })).toBe(false) + }) + + it("should detect Enter for submit", () => { + const isSubmitKey = (key: { return: boolean; ctrl: boolean }) => key.return && !key.ctrl + expect(isSubmitKey({ return: true, ctrl: false })).toBe(true) + expect(isSubmitKey({ return: true, ctrl: true })).toBe(false) + expect(isSubmitKey({ return: false, ctrl: false })).toBe(false) + }) + }) + + describe("newline insertion", () => { + it("should insert newline at cursor position", () => { + const value = "hello" + const cursorIndex = 2 + const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) + expect(newValue).toBe("he\nllo") + }) + + it("should insert newline at end", () => { + const value = "hello" + const cursorIndex = 5 + const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) + expect(newValue).toBe("hello\n") + }) + + it("should insert newline at start", () => { + const value = "hello" + const cursorIndex = 0 + const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) + expect(newValue).toBe("\nhello") + }) + }) + + describe("backspace behavior", () => { + it("should delete character before cursor", () => { + const value = "hello" + const cursorIndex = 3 + const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) + expect(newValue).toBe("helo") + }) + + it("should delete newline character (merge lines)", () => { + const value = "hello\nworld" + const cursorIndex = 6 // Start of "world" line + const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) + expect(newValue).toBe("helloworld") + }) + + it("should do nothing at start of input", () => { + const value = "hello" + const cursorIndex = 0 + // In real implementation, we check if cursorIndex > 0 + if (cursorIndex > 0) { + const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) + expect(newValue).not.toBe(value) + } + // At position 0, backspace does nothing + expect(value).toBe("hello") + }) + }) + + describe("arrow key navigation", () => { + describe("up arrow", () => { + it("should move to previous line preserving column", () => { + const value = "hello\nworld" + const getCursorPosition = (v: string, i: number) => { + const lines = v.split("\n") + let pos = 0 + for (let li = 0; li < lines.length; li++) { + const line = lines[li]! + const lineEnd = pos + line.length + if (i <= lineEnd) { + return { line: li, col: i - pos } + } + pos = lineEnd + 1 + } + return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length } + } + + const getIndexFromPosition = (v: string, line: number, col: number) => { + const lines = v.split("\n") + let index = 0 + for (let i = 0; i < line && i < lines.length; i++) { + index += lines[i]!.length + 1 + } + const targetLine = lines[line] || "" + index += Math.min(col, targetLine.length) + return index + } + + // Cursor at "world"[2] (index 8) + const cursorIndex = 8 + const { line, col } = getCursorPosition(value, cursorIndex) + expect(line).toBe(1) + expect(col).toBe(2) + + // Move up: should go to line 0, same column + const targetLine = 0 + const newIndex = getIndexFromPosition(value, targetLine, col) + expect(newIndex).toBe(2) // "he|llo" + }) + + it("should clamp column if target line is shorter", () => { + const value = "hi\nworld" + const getIndexFromPosition = (v: string, line: number, col: number) => { + const lines = v.split("\n") + let index = 0 + for (let i = 0; i < line && i < lines.length; i++) { + index += lines[i]!.length + 1 + } + const targetLine = lines[line] || "" + index += Math.min(col, targetLine.length) + return index + } + + // Cursor at "world"[4] (index 7) + // Moving up to "hi" which is only 2 chars, should clamp to col 2 + const targetLine = 0 + const col = 4 + const newIndex = getIndexFromPosition(value, targetLine, col) + expect(newIndex).toBe(2) // End of "hi" + }) + }) + + describe("down arrow", () => { + it("should move to next line preserving column", () => { + const value = "hello\nworld" + const getIndexFromPosition = (v: string, line: number, col: number) => { + const lines = v.split("\n") + let index = 0 + for (let i = 0; i < line && i < lines.length; i++) { + index += lines[i]!.length + 1 + } + const targetLine = lines[line] || "" + index += Math.min(col, targetLine.length) + return index + } + + // Cursor at "hello"[2] (index 2) + const col = 2 + const targetLine = 1 + const newIndex = getIndexFromPosition(value, targetLine, col) + expect(newIndex).toBe(8) // "wo|rld" + }) + }) + + describe("left/right arrows", () => { + it("should move left by 1", () => { + const cursorIndex = 5 + const newIndex = Math.max(0, cursorIndex - 1) + expect(newIndex).toBe(4) + }) + + it("should not move left past 0", () => { + const cursorIndex = 0 + const newIndex = Math.max(0, cursorIndex - 1) + expect(newIndex).toBe(0) + }) + + it("should move right by 1", () => { + const value = "hello" + const cursorIndex = 2 + const newIndex = Math.min(value.length, cursorIndex + 1) + expect(newIndex).toBe(3) + }) + + it("should not move right past end", () => { + const value = "hello" + const cursorIndex = 5 + const newIndex = Math.min(value.length, cursorIndex + 1) + expect(newIndex).toBe(5) + }) + }) + }) +}) + +describe("multi-line history integration", () => { + it("should store multi-line entries with newlines", () => { + const entry = "foo\nbar\nbaz" + expect(entry.includes("\n")).toBe(true) + expect(entry.split("\n").length).toBe(3) + }) + + it("should restore multi-line entries correctly", () => { + const storedEntry = "foo\nbar\nbaz" + const lines = storedEntry.split("\n") + expect(lines).toEqual(["foo", "bar", "baz"]) + }) +}) diff --git a/apps/cli/src/__tests__/useInputHistory.test.ts b/apps/cli/src/__tests__/useInputHistory.test.ts index d6be84275d..5d8d07d5ee 100644 --- a/apps/cli/src/__tests__/useInputHistory.test.ts +++ b/apps/cli/src/__tests__/useInputHistory.test.ts @@ -4,7 +4,6 @@ vi.mock("../utils/historyStorage.js") // Track state and callbacks for testing. let mockState: Record = {} -let mockInputHandler: ((input: string, key: { upArrow: boolean; downArrow: boolean }) => void) | null = null let effectCallbacks: Array<() => void | (() => void)> = [] vi.mock("react", () => ({ @@ -31,22 +30,10 @@ vi.mock("react", () => ({ useRef: vi.fn((initial: unknown) => ({ current: initial })), })) -vi.mock("ink", () => ({ - useInput: vi.fn( - ( - handler: (input: string, key: { upArrow: boolean; downArrow: boolean }) => void, - _options?: { isActive?: boolean }, - ) => { - mockInputHandler = handler - }, - ), -})) - describe("useInputHistory", () => { beforeEach(() => { vi.resetAllMocks() mockState = {} - mockInputHandler = null effectCallbacks = [] // Default mock for loadHistory @@ -110,11 +97,12 @@ describe("useInputHistory", () => { expect(result.isBrowsing).toBe(false) }) - it("should register input handler with ink useInput", async () => { + it("should export navigateUp and navigateDown functions for manual navigation", async () => { const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") - useInputHistory() + const result = useInputHistory() - expect(mockInputHandler).not.toBeNull() + expect(typeof result.navigateUp).toBe("function") + expect(typeof result.navigateDown).toBe("function") }) }) @@ -138,9 +126,13 @@ describe("useInputHistory", () => { expect(result).toHaveProperty("resetBrowsing") expect(result).toHaveProperty("history") expect(result).toHaveProperty("draft") + expect(result).toHaveProperty("navigateUp") + expect(result).toHaveProperty("navigateDown") expect(typeof result.addEntry).toBe("function") expect(typeof result.resetBrowsing).toBe("function") + expect(typeof result.navigateUp).toBe("function") + expect(typeof result.navigateDown).toBe("function") expect(Array.isArray(result.history)).toBe(true) }) }) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 5ddae9b45f..6b20ef3f7f 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -1,5 +1,5 @@ import { Box, Text, useApp, useInput } from "ink" -import { TextInput, Select } from "@inkjs/ui" +import { Select } from "@inkjs/ui" import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { EventEmitter } from "events" import { randomUUID } from "crypto" @@ -745,20 +745,26 @@ export function App({ ) : ( - - > - { - // Only submit if there's actual text - if (text && text.trim()) { - handleSubmit(text) - setShowCustomInput(false) - isTransitioningToCustomInput.current = false - } - }} - /> - + { + // Only submit if there's actual text + if (text && text.trim()) { + handleSubmit(text) + setShowCustomInput(false) + isTransitioningToCustomInput.current = false + } + }} + isActive={true} + onFileSearch={handleFileSearch} + onFileSelect={handleFileSelect} + onFilePickerClose={handleFilePickerClose} + fileSearchResults={fileSearchResults} + isFilePickerOpen={isFilePickerOpen} + filePickerSelectedIndex={filePickerSelectedIndex} + onFilePickerIndexChange={setFilePickerSelectedIndex} + prompt="> " + /> {statusBarMessage} @@ -775,21 +781,19 @@ export function App({ ) : isComplete ? ( - - > - - + {!isFilePickerOpen && statusBarMessage} {isFilePickerOpen && ( @@ -807,21 +811,19 @@ export function App({ ) : ( - - - - + {!isFilePickerOpen && statusBarMessage} {isFilePickerOpen && ( diff --git a/apps/cli/src/ui/components/FilePickerInput.tsx b/apps/cli/src/ui/components/FilePickerInput.tsx index 6b1da785f6..8543948954 100644 --- a/apps/cli/src/ui/components/FilePickerInput.tsx +++ b/apps/cli/src/ui/components/FilePickerInput.tsx @@ -1,7 +1,7 @@ import { useInput } from "ink" -import { TextInput } from "@inkjs/ui" import { useState, useCallback, useEffect, useRef } from "react" +import { MultilineTextInput } from "./MultilineTextInput.js" import { useInputHistory } from "../hooks/useInputHistory.js" import type { FileSearchResult } from "../types.js" @@ -16,6 +16,14 @@ export interface FilePickerInputProps { onFileSelect: (result: FileSearchResult) => void onFilePickerClose: () => void onFilePickerIndexChange: (index: number) => void + /** + * Prompt character for the first line (default: "> ") + */ + prompt?: string + /** + * Indent string for continuation lines (default: " ") + */ + continuationIndent?: string } const SEARCH_DEBOUNCE_MS = 150 @@ -29,39 +37,38 @@ export function FilePickerInput({ isFilePickerOpen, filePickerSelectedIndex, onFilePickerClose, + prompt = "> ", + continuationIndent = " ", }: FilePickerInputProps) { - const currentInputRef = useRef("") const debounceTimerRef = useRef(null) const lastSearchQueryRef = useRef(null) - const [inputKey, setInputKey] = useState(0) - const [displayValue, setDisplayValue] = useState("") + const [inputValue, setInputValue] = useState("") - const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft } = useInputHistory({ - isActive: isActive && !isFilePickerOpen, - getCurrentInput: () => currentInputRef.current, - }) + const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft, navigateUp, navigateDown } = + useInputHistory({ + isActive: isActive && !isFilePickerOpen, + getCurrentInput: () => inputValue, + }) const [wasBrowsing, setWasBrowsing] = useState(false) + // Handle history navigation useEffect(() => { if (isBrowsing && !wasBrowsing) { if (historyValue !== null) { - setDisplayValue(historyValue) - setInputKey((k) => k + 1) + setInputValue(historyValue) } } else if (!isBrowsing && wasBrowsing) { - setDisplayValue(draft) - setInputKey((k) => k + 1) - currentInputRef.current = draft - } else if (isBrowsing && historyValue !== null && historyValue !== displayValue) { - setDisplayValue(historyValue) - setInputKey((k) => k + 1) + setInputValue(draft) + } else if (isBrowsing && historyValue !== null && historyValue !== inputValue) { + setInputValue(historyValue) } setWasBrowsing(isBrowsing) - }, [isBrowsing, wasBrowsing, historyValue, draft, displayValue]) + }, [isBrowsing, wasBrowsing, historyValue, draft, inputValue]) + // Cleanup debounce timer useEffect(() => { return () => { if (debounceTimerRef.current) { @@ -72,7 +79,10 @@ export function FilePickerInput({ const checkForAtTrigger = useCallback( (value: string) => { - const atIndex = value.lastIndexOf("@") + // Check on the last line for @ trigger + const lines = value.split("\n") + const lastLine = lines[lines.length - 1] || "" + const atIndex = lastLine.lastIndexOf("@") if (atIndex === -1) { if (isFilePickerOpen) { @@ -82,7 +92,7 @@ export function FilePickerInput({ return } - const query = value.substring(atIndex + 1) + const query = lastLine.substring(atIndex + 1) if (query.includes(" ")) { if (isFilePickerOpen) { @@ -116,8 +126,7 @@ export function FilePickerInput({ const handleChange = useCallback( (value: string) => { - currentInputRef.current = value - + setInputValue(value) checkForAtTrigger(value) if (!isBrowsing) { @@ -129,23 +138,24 @@ export function FilePickerInput({ const handleFileSelect = useCallback( (result: FileSearchResult) => { - const currentValue = currentInputRef.current - const atIndex = currentValue.lastIndexOf("@") + // Find and replace the @ trigger on the last line + const lines = inputValue.split("\n") + const lastLineIndex = lines.length - 1 + const lastLine = lines[lastLineIndex] || "" + const atIndex = lastLine.lastIndexOf("@") if (atIndex !== -1) { - const beforeAt = currentValue.substring(0, atIndex) - const newValue = `${beforeAt}@/${result.path} ` + const beforeAt = lastLine.substring(0, atIndex) + lines[lastLineIndex] = `${beforeAt}@/${result.path} ` + const newValue = lines.join("\n") - currentInputRef.current = newValue - setDisplayValue(newValue) - setInputKey((k) => k + 1) + setInputValue(newValue) setDraft(newValue) - lastSearchQueryRef.current = null onFilePickerClose() } }, - [onFilePickerClose, setDraft], + [inputValue, onFilePickerClose, setDraft], ) const handleSubmit = useCallback( @@ -163,16 +173,26 @@ export function FilePickerInput({ await addEntry(trimmed) resetBrowsing("") - currentInputRef.current = "" lastSearchQueryRef.current = null - setDisplayValue("") - setInputKey((k) => k + 1) + setInputValue("") onSubmit(trimmed) }, [isFilePickerOpen, addEntry, resetBrowsing, onSubmit], ) + const handleEscape = useCallback(() => { + // Clear all input on Escape + setInputValue("") + setDraft("") + resetBrowsing("") + lastSearchQueryRef.current = null + if (isFilePickerOpen) { + onFilePickerClose() + } + }, [setDraft, resetBrowsing, isFilePickerOpen, onFilePickerClose]) + + // Handle file picker selection with Enter useInput( (_input, key) => { if (!isActive || !isFilePickerOpen) { @@ -191,12 +211,19 @@ export function FilePickerInput({ ) return ( - ) } diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx new file mode 100644 index 0000000000..2b367554d6 --- /dev/null +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -0,0 +1,285 @@ +/** + * MultilineTextInput Component + * + * A multi-line text input for Ink CLI applications. + * Based on ink-multiline-input but simplified for our needs. + * + * Key behaviors: + * - Ctrl+Enter: Add new line + * - Enter: Submit + * - Backspace at start of line: Merge with previous line + * - Escape: Clear all lines + * - Arrow keys: Navigate within and between lines + */ + +import { useState, useEffect, useMemo, useCallback } from "react" +import { Box, Text, useInput, type Key } from "ink" + +export interface MultilineTextInputProps { + /** + * Current value (can contain newlines) + */ + value: string + /** + * Called when the value changes + */ + onChange: (value: string) => void + /** + * Called when user submits (Enter without Ctrl) + */ + onSubmit?: (value: string) => void + /** + * Called when user presses Escape + */ + onEscape?: () => void + /** + * Called when up arrow is pressed while cursor is on the first line + * Use this to trigger history navigation + */ + onUpAtFirstLine?: () => void + /** + * Called when down arrow is pressed while cursor is on the last line + * Use this to trigger history navigation + */ + onDownAtLastLine?: () => void + /** + * Placeholder text when empty + */ + placeholder?: string + /** + * Whether the input is active/focused + */ + isActive?: boolean + /** + * Whether to show the cursor + */ + showCursor?: boolean + /** + * Prompt character for the first line + */ + prompt?: string + /** + * Indent string for continuation lines + */ + continuationIndent?: string +} + +/** + * Normalize line endings to LF (\n) + */ +function normalizeLineEndings(text: string): string { + if (text == null) return "" + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") +} + +/** + * Calculate line and column position from cursor index + */ +function getCursorPosition(value: string, cursorIndex: number): { line: number; col: number } { + const lines = value.split("\n") + let pos = 0 + for (let i = 0; i < lines.length; i++) { + const line = lines[i]! + const lineEnd = pos + line.length + if (cursorIndex <= lineEnd) { + return { line: i, col: cursorIndex - pos } + } + pos = lineEnd + 1 // +1 for newline + } + // Cursor at very end + return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length } +} + +/** + * Calculate cursor index from line and column position + */ +function getIndexFromPosition(value: string, line: number, col: number): number { + const lines = value.split("\n") + let index = 0 + for (let i = 0; i < line && i < lines.length; i++) { + index += lines[i]!.length + 1 // +1 for newline + } + const targetLine = lines[line] || "" + index += Math.min(col, targetLine.length) + return index +} + +export function MultilineTextInput({ + value, + onChange, + onSubmit, + onEscape, + onUpAtFirstLine, + onDownAtLastLine, + placeholder = "", + isActive = true, + showCursor = true, + prompt = "> ", + continuationIndent = " ", +}: MultilineTextInputProps) { + const [cursorIndex, setCursorIndex] = useState(value.length) + + // Clamp cursor if value changes externally + useEffect(() => { + if (cursorIndex > value.length) { + setCursorIndex(value.length) + } + }, [value, cursorIndex]) + + // Handle keyboard input + useInput( + (input: string, key: Key) => { + // Escape: clear all + if (key.escape) { + onEscape?.() + return + } + + // Ctrl+C: ignore (handled elsewhere) + if (key.ctrl && input === "c") { + return + } + + // Ctrl+Enter: add new line + if (key.return && key.ctrl) { + const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex) + onChange(newValue) + setCursorIndex(cursorIndex + 1) + return + } + + // Enter (without Ctrl): submit + if (key.return) { + onSubmit?.(value) + return + } + + // Tab: ignore for now + if (key.tab) { + return + } + + // Arrow up: move cursor up one line, or trigger history if on first line + if (key.upArrow) { + if (!showCursor) return + const lines = value.split("\n") + const { line, col } = getCursorPosition(value, cursorIndex) + + if (line > 0) { + // Move to previous line + const targetLine = lines[line - 1]! + const newCol = Math.min(col, targetLine.length) + setCursorIndex(getIndexFromPosition(value, line - 1, newCol)) + } else { + // On first line - trigger history navigation callback + onUpAtFirstLine?.() + } + return + } + + // Arrow down: move cursor down one line, or trigger history if on last line + if (key.downArrow) { + if (!showCursor) return + const lines = value.split("\n") + const { line, col } = getCursorPosition(value, cursorIndex) + + if (line < lines.length - 1) { + // Move to next line + const targetLine = lines[line + 1]! + const newCol = Math.min(col, targetLine.length) + setCursorIndex(getIndexFromPosition(value, line + 1, newCol)) + } else { + // On last line - trigger history navigation callback + onDownAtLastLine?.() + } + return + } + + // Arrow left: move cursor left + if (key.leftArrow) { + if (!showCursor) return + setCursorIndex(Math.max(0, cursorIndex - 1)) + return + } + + // Arrow right: move cursor right + if (key.rightArrow) { + if (!showCursor) return + setCursorIndex(Math.min(value.length, cursorIndex + 1)) + return + } + + // Backspace/Delete + if (key.backspace || key.delete) { + if (cursorIndex > 0) { + const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex) + onChange(newValue) + setCursorIndex(cursorIndex - 1) + } + return + } + + // Normal character input + if (input) { + const normalized = normalizeLineEndings(input) + const newValue = value.slice(0, cursorIndex) + normalized + value.slice(cursorIndex) + onChange(newValue) + setCursorIndex(cursorIndex + normalized.length) + } + }, + { isActive }, + ) + + // Split value into lines for rendering + const lines = useMemo(() => { + if (!value && !isActive) { + return [placeholder] + } + if (!value) { + return [""] + } + return value.split("\n") + }, [value, placeholder, isActive]) + + // Determine which line and column the cursor is on + const cursorPosition = useMemo(() => { + if (!showCursor || !isActive) return null + return getCursorPosition(value, cursorIndex) + }, [value, cursorIndex, showCursor, isActive]) + + // Render a line with optional cursor + const renderLine = useCallback( + (lineText: string, lineIndex: number) => { + const isPlaceholder = !value && !isActive && lineIndex === 0 + const isFirstLine = lineIndex === 0 + const linePrefix = isFirstLine ? prompt : continuationIndent + + // Check if cursor is on this line + if (cursorPosition && cursorPosition.line === lineIndex && isActive) { + const { col } = cursorPosition + const beforeCursor = lineText.slice(0, col) + const cursorChar = lineText[col] || " " + const afterCursor = lineText.slice(col + 1) + + return ( + + {linePrefix} + {beforeCursor} + {cursorChar} + {afterCursor} + + ) + } + + return ( + + {linePrefix} + {lineText} + + ) + }, + [prompt, continuationIndent, cursorPosition, value, isActive], + ) + + return {lines.map((line, index) => renderLine(line, index))} +} diff --git a/apps/cli/src/ui/hooks/useInputHistory.ts b/apps/cli/src/ui/hooks/useInputHistory.ts index 32e74aac50..3a698f43ca 100644 --- a/apps/cli/src/ui/hooks/useInputHistory.ts +++ b/apps/cli/src/ui/hooks/useInputHistory.ts @@ -2,18 +2,17 @@ * useInputHistory Hook * * Provides input history navigation for CLI text inputs. - * Uses up/down arrow keys to navigate through previously entered prompts. + * Navigation is triggered via navigateUp/navigateDown functions. * History is persisted to ~/.roo/cli-history.json */ import { useState, useEffect, useCallback, useRef } from "react" -import { useInput } from "ink" import { loadHistory, addToHistory } from "../../utils/historyStorage.js" export interface UseInputHistoryOptions { /** - * Whether the hook should respond to arrow key input. + * Whether the hook should respond to navigation calls. * Set to false when input is not active/focused. * @default true */ @@ -63,6 +62,16 @@ export interface UseInputHistoryReturn { * Set the current draft value (call from onChange) */ setDraft: (value: string) => void + + /** + * Navigate to older history entry (call when up arrow at first line) + */ + navigateUp: () => void + + /** + * Navigate to newer history entry (call when down arrow at last line) + */ + navigateDown: () => void } /** @@ -112,42 +121,38 @@ export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputH } }, []) - // Handle up/down arrow keys for history navigation - useInput( - (_input, key) => { - if (!isActive) return + // Navigate to older history entry + const navigateUp = useCallback(() => { + if (!isActive) return + if (history.length === 0) return - if (key.upArrow) { - // Navigate to older entry - if (history.length === 0) return - - if (historyIndex === -1) { - // Starting to browse - save current input as draft - if (getCurrentInput) { - setDraft(getCurrentInput()) - } - // Go to newest entry - setHistoryIndex(history.length - 1) - } else if (historyIndex > 0) { - // Go to older entry - setHistoryIndex(historyIndex - 1) - } - // At oldest entry - stay there - } else if (key.downArrow) { - // Navigate to newer entry - if (historyIndex === -1) return // Not browsing - - if (historyIndex < history.length - 1) { - // Go to newer entry - setHistoryIndex(historyIndex + 1) - } else { - // At newest entry - return to draft - setHistoryIndex(-1) - } + if (historyIndex === -1) { + // Starting to browse - save current input as draft + if (getCurrentInput) { + setDraft(getCurrentInput()) } - }, - { isActive }, - ) + // Go to newest entry + setHistoryIndex(history.length - 1) + } else if (historyIndex > 0) { + // Go to older entry + setHistoryIndex(historyIndex - 1) + } + // At oldest entry - stay there + }, [isActive, history, historyIndex, getCurrentInput]) + + // Navigate to newer history entry + const navigateDown = useCallback(() => { + if (!isActive) return + if (historyIndex === -1) return // Not browsing + + if (historyIndex < history.length - 1) { + // Go to newer entry + setHistoryIndex(historyIndex + 1) + } else { + // At newest entry - return to draft + setHistoryIndex(-1) + } + }, [isActive, historyIndex, history.length]) // Add new entry to history const addEntry = useCallback(async (entry: string) => { @@ -191,5 +196,7 @@ export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputH history, draft, setDraft, + navigateUp, + navigateDown, } }