mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Change the text input to multiline
This commit is contained in:
parent
ad6ce88583
commit
930755e44a
6 changed files with 791 additions and 135 deletions
343
apps/cli/src/__tests__/MultilineTextInput.test.ts
Normal file
343
apps/cli/src/__tests__/MultilineTextInput.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}>> </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}>> </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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
285
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal file
285
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue