Change the text input to multiline

This commit is contained in:
cte 2026-01-06 03:18:45 -08:00
parent ad6ce88583
commit 930755e44a
6 changed files with 791 additions and 135 deletions

View file

@ -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"])
})
})

View file

@ -4,7 +4,6 @@ vi.mock("../utils/historyStorage.js")
// Track state and callbacks for testing.
let mockState: Record<string, unknown> = {}
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)
})
})

View file

@ -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({
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine />
<Box>
<Text color={theme.promptColor}>&gt; </Text>
<TextInput
placeholder="Type your response..."
onSubmit={(text) => {
// Only submit if there's actual text
if (text && text.trim()) {
handleSubmit(text)
setShowCustomInput(false)
isTransitioningToCustomInput.current = false
}
}}
/>
</Box>
<FilePickerInput
placeholder="Type your response..."
onSubmit={(text: string) => {
// 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="> "
/>
<HorizontalLine />
{statusBarMessage}
</Box>
@ -775,21 +781,19 @@ export function App({
) : isComplete ? (
<Box flexDirection="column">
<HorizontalLine />
<Box>
<Text color={theme.promptColor}>&gt; </Text>
<FilePickerInput
placeholder="Type to continue..."
onSubmit={handleSubmit}
isActive={view === "UserInput"}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
onFilePickerClose={handleFilePickerClose}
fileSearchResults={fileSearchResults}
isFilePickerOpen={isFilePickerOpen}
filePickerSelectedIndex={filePickerSelectedIndex}
onFilePickerIndexChange={setFilePickerSelectedIndex}
/>
</Box>
<FilePickerInput
placeholder="Type to continue..."
onSubmit={handleSubmit}
isActive={view === "UserInput"}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
onFilePickerClose={handleFilePickerClose}
fileSearchResults={fileSearchResults}
isFilePickerOpen={isFilePickerOpen}
filePickerSelectedIndex={filePickerSelectedIndex}
onFilePickerIndexChange={setFilePickerSelectedIndex}
prompt="> "
/>
<HorizontalLine />
{!isFilePickerOpen && statusBarMessage}
{isFilePickerOpen && (
@ -807,21 +811,19 @@ export function App({
) : (
<Box flexDirection="column">
<HorizontalLine />
<Box>
<Text color={theme.promptColor}> </Text>
<FilePickerInput
placeholder=""
onSubmit={handleSubmit}
isActive={view === "UserInput"}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
onFilePickerClose={handleFilePickerClose}
fileSearchResults={fileSearchResults}
isFilePickerOpen={isFilePickerOpen}
filePickerSelectedIndex={filePickerSelectedIndex}
onFilePickerIndexChange={setFilePickerSelectedIndex}
/>
</Box>
<FilePickerInput
placeholder=""
onSubmit={handleSubmit}
isActive={view === "UserInput"}
onFileSearch={handleFileSearch}
onFileSelect={handleFileSelect}
onFilePickerClose={handleFilePickerClose}
fileSearchResults={fileSearchResults}
isFilePickerOpen={isFilePickerOpen}
filePickerSelectedIndex={filePickerSelectedIndex}
onFilePickerIndexChange={setFilePickerSelectedIndex}
prompt=" "
/>
<HorizontalLine />
{!isFilePickerOpen && statusBarMessage}
{isFilePickerOpen && (

View file

@ -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<NodeJS.Timeout | null>(null)
const lastSearchQueryRef = useRef<string | null>(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 (
<TextInput
key={`file-picker-input-${inputKey}-${history.length}`}
defaultValue={displayValue}
placeholder={placeholder}
<MultilineTextInput
key={`file-picker-input-${history.length}`}
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
onEscape={handleEscape}
onUpAtFirstLine={navigateUp}
onDownAtLastLine={navigateDown}
placeholder={placeholder}
isActive={isActive}
showCursor={true}
prompt={prompt}
continuationIndent={continuationIndent}
/>
)
}

View file

@ -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 (
<Box key={lineIndex}>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Text>{beforeCursor}</Text>
<Text inverse>{cursorChar}</Text>
<Text>{afterCursor}</Text>
</Box>
)
}
return (
<Box key={lineIndex}>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Text dimColor={isPlaceholder}>{lineText}</Text>
</Box>
)
},
[prompt, continuationIndent, cursorPosition, value, isActive],
)
return <Box flexDirection="column">{lines.map((line, index) => renderLine(line, index))}</Box>
}

View file

@ -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,
}
}