mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
Add file picker
This commit is contained in:
parent
c137cf44c8
commit
ad6ce88583
8 changed files with 918 additions and 156 deletions
255
apps/cli/src/__tests__/FilePickerInput.test.ts
Normal file
255
apps/cli/src/__tests__/FilePickerInput.test.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
vi.mock("ink", () => ({
|
||||
Box: ({ children }: { children: React.ReactNode }) => children,
|
||||
Text: ({ children }: { children: React.ReactNode }) => children,
|
||||
useInput: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@inkjs/ui", () => ({
|
||||
TextInput: ({ onChange: _ }: { onChange: (value: string) => void }) => null,
|
||||
}))
|
||||
|
||||
vi.mock("../ui/components/FilePickerSelect.js", () => ({
|
||||
FilePickerSelect: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("../ui/hooks/useInputHistory.js", () => ({
|
||||
useInputHistory: () => ({
|
||||
addEntry: vi.fn(),
|
||||
historyValue: null,
|
||||
isBrowsing: false,
|
||||
resetBrowsing: vi.fn(),
|
||||
history: [],
|
||||
draft: "",
|
||||
setDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe("FilePickerInput @ trigger detection", () => {
|
||||
describe("checkForAtTrigger logic", () => {
|
||||
// Test the @ trigger detection logic in isolation.
|
||||
const checkForAtTrigger = (
|
||||
value: string,
|
||||
isFilePickerOpen: boolean,
|
||||
): { shouldSearch: boolean; shouldClose: boolean; query: string } => {
|
||||
const atIndex = value.lastIndexOf("@")
|
||||
|
||||
if (atIndex === -1) {
|
||||
return { shouldSearch: false, shouldClose: isFilePickerOpen, query: "" }
|
||||
}
|
||||
|
||||
const query = value.substring(atIndex + 1)
|
||||
|
||||
// Check if query contains a space (user finished typing file path).
|
||||
if (query.includes(" ")) {
|
||||
return { shouldSearch: false, shouldClose: isFilePickerOpen, query }
|
||||
}
|
||||
|
||||
// Require at least 1 character after @ to trigger search.
|
||||
if (query.length === 0) {
|
||||
return { shouldSearch: false, shouldClose: isFilePickerOpen, query }
|
||||
}
|
||||
|
||||
return { shouldSearch: true, shouldClose: false, query }
|
||||
}
|
||||
|
||||
it("should detect @ with characters after it", () => {
|
||||
const result = checkForAtTrigger("hello @src", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(true)
|
||||
expect(result.query).toBe("src")
|
||||
})
|
||||
|
||||
it("should not trigger with just @", () => {
|
||||
const result = checkForAtTrigger("hello @", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(false)
|
||||
expect(result.query).toBe("")
|
||||
})
|
||||
|
||||
it("should not trigger without @", () => {
|
||||
const result = checkForAtTrigger("hello world", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(false)
|
||||
})
|
||||
|
||||
it("should use last @ when multiple exist", () => {
|
||||
const result = checkForAtTrigger("@first @second", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(true)
|
||||
expect(result.query).toBe("second")
|
||||
})
|
||||
|
||||
it("should close picker when space is added after file path", () => {
|
||||
const result = checkForAtTrigger("@/src/file.ts ", true)
|
||||
|
||||
expect(result.shouldSearch).toBe(false)
|
||||
expect(result.shouldClose).toBe(true)
|
||||
})
|
||||
|
||||
it("should support searching with partial file names", () => {
|
||||
const result = checkForAtTrigger("@App", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(true)
|
||||
expect(result.query).toBe("App")
|
||||
})
|
||||
|
||||
it("should support searching with path separators", () => {
|
||||
const result = checkForAtTrigger("@src/utils", false)
|
||||
|
||||
expect(result.shouldSearch).toBe(true)
|
||||
expect(result.query).toBe("src/utils")
|
||||
})
|
||||
|
||||
it("should close picker when @ is deleted", () => {
|
||||
const result = checkForAtTrigger("hello world", true)
|
||||
|
||||
expect(result.shouldSearch).toBe(false)
|
||||
expect(result.shouldClose).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("file selection formatting", () => {
|
||||
const formatFileSelection = (
|
||||
currentValue: string,
|
||||
selectedPath: string,
|
||||
): { newValue: string; atIndex: number } => {
|
||||
const atIndex = currentValue.lastIndexOf("@")
|
||||
|
||||
if (atIndex === -1) {
|
||||
return { newValue: currentValue, atIndex: -1 }
|
||||
}
|
||||
|
||||
const beforeAt = currentValue.substring(0, atIndex)
|
||||
const newValue = `${beforeAt}@/${selectedPath} `
|
||||
|
||||
return { newValue, atIndex }
|
||||
}
|
||||
|
||||
it("should format selected file as @/{path}", () => {
|
||||
const result = formatFileSelection("hello @src", "src/utils/helper.ts")
|
||||
|
||||
expect(result.newValue).toBe("hello @/src/utils/helper.ts ")
|
||||
expect(result.atIndex).toBe(6)
|
||||
})
|
||||
|
||||
it("should format selected folder as @/{path}", () => {
|
||||
const result = formatFileSelection("@App", "apps/cli")
|
||||
|
||||
expect(result.newValue).toBe("@/apps/cli ")
|
||||
expect(result.atIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should preserve text before @", () => {
|
||||
const result = formatFileSelection("check this file @test", "tests/unit.test.ts")
|
||||
|
||||
expect(result.newValue).toBe("check this file @/tests/unit.test.ts ")
|
||||
})
|
||||
|
||||
it("should add space after selected file", () => {
|
||||
const result = formatFileSelection("@config", "config/settings.json")
|
||||
|
||||
expect(result.newValue).toMatch(/ $/) // Ends with space.
|
||||
})
|
||||
|
||||
it("should handle @ at start of input", () => {
|
||||
const result = formatFileSelection("@file", "package.json")
|
||||
|
||||
expect(result.newValue).toBe("@/package.json ")
|
||||
expect(result.atIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle multiple @ by using the last one", () => {
|
||||
const result = formatFileSelection("email@test.com @src", "src/index.ts")
|
||||
|
||||
expect(result.newValue).toBe("email@test.com @/src/index.ts ")
|
||||
})
|
||||
|
||||
it("should return unchanged if no @ found", () => {
|
||||
const result = formatFileSelection("hello world", "some/file.ts")
|
||||
|
||||
expect(result.newValue).toBe("hello world")
|
||||
expect(result.atIndex).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("debounce behavior", () => {
|
||||
it("should debounce consecutive searches", async () => {
|
||||
const DEBOUNCE_MS = 150
|
||||
const searchFn = vi.fn()
|
||||
|
||||
// Simulate debouncing.
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
|
||||
const debouncedSearch = (query: string) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
searchFn(query)
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Rapid calls.
|
||||
debouncedSearch("s")
|
||||
debouncedSearch("sr")
|
||||
debouncedSearch("src")
|
||||
|
||||
// Immediately, no calls should have been made.
|
||||
expect(searchFn).not.toHaveBeenCalled()
|
||||
|
||||
// Wait for debounce.
|
||||
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_MS + 50))
|
||||
|
||||
// Only final call should execute.
|
||||
expect(searchFn).toHaveBeenCalledTimes(1)
|
||||
expect(searchFn).toHaveBeenCalledWith("src")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("FilePickerInput store integration", () => {
|
||||
describe("file picker state shape", () => {
|
||||
it("should have expected initial state", () => {
|
||||
const initialState = {
|
||||
fileSearchResults: [],
|
||||
isFilePickerOpen: false,
|
||||
filePickerQuery: "",
|
||||
filePickerSelectedIndex: 0,
|
||||
}
|
||||
|
||||
expect(initialState.fileSearchResults).toEqual([])
|
||||
expect(initialState.isFilePickerOpen).toBe(false)
|
||||
expect(initialState.filePickerQuery).toBe("")
|
||||
expect(initialState.filePickerSelectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should reset selectedIndex when results change", () => {
|
||||
// Simulate setFileSearchResults behavior.
|
||||
const setFileSearchResults = (results: unknown[]) => ({
|
||||
fileSearchResults: results,
|
||||
filePickerSelectedIndex: 0, // Always reset to 0.
|
||||
})
|
||||
|
||||
const newResults = [{ path: "file1.ts", type: "file" }]
|
||||
const state = setFileSearchResults(newResults)
|
||||
|
||||
expect(state.filePickerSelectedIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should clear all picker state on clearFilePicker", () => {
|
||||
const clearFilePicker = () => ({
|
||||
fileSearchResults: [],
|
||||
isFilePickerOpen: false,
|
||||
filePickerQuery: "",
|
||||
filePickerSelectedIndex: 0,
|
||||
})
|
||||
|
||||
const state = clearFilePicker()
|
||||
|
||||
expect(state.fileSearchResults).toEqual([])
|
||||
expect(state.isFilePickerOpen).toBe(false)
|
||||
expect(state.filePickerQuery).toBe("")
|
||||
expect(state.filePickerSelectedIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
218
apps/cli/src/__tests__/FilePickerSelect.test.ts
Normal file
218
apps/cli/src/__tests__/FilePickerSelect.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
let _mockInputHandler: ((input: string, key: Record<string, boolean>) => void) | null = null
|
||||
let _mockInputOptions: { isActive?: boolean } | null = null
|
||||
|
||||
vi.mock("ink", () => ({
|
||||
Box: ({ children }: { children: React.ReactNode }) => children,
|
||||
Text: ({ children }: { children: React.ReactNode }) => children,
|
||||
useInput: vi.fn(
|
||||
(handler: (input: string, key: Record<string, boolean>) => void, options?: { isActive?: boolean }) => {
|
||||
_mockInputHandler = handler
|
||||
_mockInputOptions = options || null
|
||||
},
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: vi.fn((callback: () => void | (() => void)) => {
|
||||
callback()
|
||||
}),
|
||||
useCallback: vi.fn((callback: unknown) => callback),
|
||||
useMemo: vi.fn((callback: () => unknown) => callback()),
|
||||
}))
|
||||
|
||||
import type { FileSearchResult } from "../ui/types.js"
|
||||
|
||||
describe("FilePickerSelect", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
_mockInputHandler = null
|
||||
_mockInputOptions = null
|
||||
})
|
||||
|
||||
describe("scroll window calculation", () => {
|
||||
it("should show all items when results fit within maxVisible", () => {
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
{ path: "folder1", type: "folder" },
|
||||
]
|
||||
|
||||
// Compute visible window like the component does.
|
||||
const maxVisible = 10
|
||||
const selectedIndex = 0
|
||||
|
||||
let offset = 0
|
||||
let visibleResults = results
|
||||
|
||||
if (results.length > maxVisible) {
|
||||
const idealOffset = Math.max(0, selectedIndex - Math.floor(maxVisible / 2))
|
||||
offset = Math.min(idealOffset, results.length - maxVisible)
|
||||
visibleResults = results.slice(offset, offset + maxVisible)
|
||||
}
|
||||
|
||||
expect(visibleResults.length).toBe(3)
|
||||
expect(offset).toBe(0)
|
||||
})
|
||||
|
||||
it("should scroll down when selected item is beyond maxVisible", () => {
|
||||
const results: FileSearchResult[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
path: `file${i}.ts`,
|
||||
type: "file" as const,
|
||||
}))
|
||||
|
||||
const maxVisible = 10
|
||||
const selectedIndex = 15
|
||||
|
||||
let offset = 0
|
||||
|
||||
if (results.length > maxVisible) {
|
||||
const idealOffset = Math.max(0, selectedIndex - Math.floor(maxVisible / 2))
|
||||
offset = Math.min(idealOffset, results.length - maxVisible)
|
||||
}
|
||||
|
||||
const visibleResults = results.slice(offset, offset + maxVisible)
|
||||
|
||||
expect(offset).toBe(10)
|
||||
expect(visibleResults.length).toBe(10)
|
||||
expect(visibleResults[0]?.path).toBe("file10.ts")
|
||||
})
|
||||
|
||||
it("should keep selected item in view when near the end", () => {
|
||||
const results: FileSearchResult[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
path: `file${i}.ts`,
|
||||
type: "file" as const,
|
||||
}))
|
||||
|
||||
const maxVisible = 10
|
||||
const selectedIndex = 14
|
||||
|
||||
let offset = 0
|
||||
|
||||
if (results.length > maxVisible) {
|
||||
const idealOffset = Math.max(0, selectedIndex - Math.floor(maxVisible / 2))
|
||||
offset = Math.min(idealOffset, results.length - maxVisible)
|
||||
}
|
||||
|
||||
const visibleResults = results.slice(offset, offset + maxVisible)
|
||||
|
||||
expect(offset).toBe(5)
|
||||
expect(visibleResults.length).toBe(10)
|
||||
// Selected item (index 14) should be at visible index 9.
|
||||
expect(selectedIndex - offset).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyboard navigation", () => {
|
||||
it("should call onIndexChange with next index on down arrow", async () => {
|
||||
const onIndexChange = vi.fn()
|
||||
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
]
|
||||
|
||||
// Import the component to trigger useInput registration.
|
||||
await import("../ui/components/FilePickerSelect.js")
|
||||
|
||||
const selectedIndex = 0
|
||||
const key = { downArrow: true, upArrow: false, escape: false, return: false }
|
||||
|
||||
if (key.downArrow) {
|
||||
const newIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : 0
|
||||
onIndexChange(newIndex)
|
||||
}
|
||||
|
||||
expect(onIndexChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it("should wrap around to first item when pressing down at end", () => {
|
||||
const onIndexChange = vi.fn()
|
||||
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
]
|
||||
|
||||
const selectedIndex = 1
|
||||
const key = { downArrow: true, upArrow: false, escape: false, return: false }
|
||||
|
||||
if (key.downArrow) {
|
||||
const newIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : 0
|
||||
onIndexChange(newIndex)
|
||||
}
|
||||
|
||||
expect(onIndexChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it("should call onIndexChange with previous index on up arrow", () => {
|
||||
const onIndexChange = vi.fn()
|
||||
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
]
|
||||
|
||||
const selectedIndex = 1
|
||||
const key = { downArrow: false, upArrow: true, escape: false, return: false }
|
||||
|
||||
if (key.upArrow) {
|
||||
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : results.length - 1
|
||||
onIndexChange(newIndex)
|
||||
}
|
||||
|
||||
expect(onIndexChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it("should wrap around to last item when pressing up at start", () => {
|
||||
const onIndexChange = vi.fn()
|
||||
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
]
|
||||
|
||||
const selectedIndex = 0
|
||||
const key = { downArrow: false, upArrow: true, escape: false, return: false }
|
||||
|
||||
if (key.upArrow) {
|
||||
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : results.length - 1
|
||||
onIndexChange(newIndex)
|
||||
}
|
||||
|
||||
expect(onIndexChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it("should call onEscape when escape is pressed", () => {
|
||||
const onEscape = vi.fn()
|
||||
const key = { downArrow: false, upArrow: false, escape: true, return: false }
|
||||
|
||||
if (key.escape) {
|
||||
onEscape()
|
||||
}
|
||||
|
||||
expect(onEscape).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call onSelect with selected item when return is pressed", () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
const results: FileSearchResult[] = [
|
||||
{ path: "file1.ts", type: "file" },
|
||||
{ path: "file2.ts", type: "file" },
|
||||
]
|
||||
|
||||
const selectedIndex = 1
|
||||
const key = { downArrow: false, upArrow: false, escape: false, return: true }
|
||||
|
||||
if (key.return) {
|
||||
const selected = results[selectedIndex]
|
||||
|
||||
if (selected) {
|
||||
onSelect(selected)
|
||||
}
|
||||
}
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ path: "file2.ts", type: "file" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -8,10 +8,11 @@ import { useCLIStore } from "./store.js"
|
|||
import Header from "./components/Header.js"
|
||||
import ChatHistoryItem from "./components/ChatHistoryItem.js"
|
||||
import LoadingText from "./components/LoadingText.js"
|
||||
import { HistoryTextInput } from "./components/HistoryTextInput.js"
|
||||
import { FilePickerInput } from "./components/FilePickerInput.js"
|
||||
import { FilePickerSelect } from "./components/FilePickerSelect.js"
|
||||
import { useTerminalSize } from "./hooks/useTerminalSize.js"
|
||||
import * as theme from "./utils/theme.js"
|
||||
import type { AppProps, TUIMessage, PendingAsk, SayType, AskType, View } from "./types.js"
|
||||
import type { AppProps, TUIMessage, PendingAsk, SayType, AskType, View, FileSearchResult } from "./types.js"
|
||||
|
||||
/**
|
||||
* Interface for the extension host that the TUI interacts with
|
||||
|
|
@ -121,7 +122,6 @@ export function App({
|
|||
}: TUIAppProps) {
|
||||
const { exit } = useApp()
|
||||
|
||||
// Zustand store
|
||||
const {
|
||||
messages,
|
||||
pendingAsk,
|
||||
|
|
@ -135,6 +135,13 @@ export function App({
|
|||
setComplete,
|
||||
setHasStartedTask,
|
||||
setError,
|
||||
fileSearchResults,
|
||||
isFilePickerOpen,
|
||||
filePickerSelectedIndex,
|
||||
setFileSearchResults,
|
||||
setFilePickerOpen,
|
||||
setFilePickerSelectedIndex,
|
||||
clearFilePicker,
|
||||
} = useCLIStore()
|
||||
|
||||
const hostRef = useRef<ExtensionHostInterface | null>(null)
|
||||
|
|
@ -170,10 +177,16 @@ export function App({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Handle Ctrl+C - require double press to exit
|
||||
// Handle Ctrl+C - close file picker first, then require double press to exit
|
||||
// Using useInput to capture in raw mode (Ink intercepts SIGINT)
|
||||
useInput((input, key) => {
|
||||
if (key.ctrl && input === "c") {
|
||||
// If file picker is open, close it first
|
||||
if (isFilePickerOpen) {
|
||||
clearFilePicker()
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingExit.current) {
|
||||
// Second press - exit immediately
|
||||
if (exitHintTimeout.current) {
|
||||
|
|
@ -439,9 +452,16 @@ export function App({
|
|||
} else if (type === "ask" && ask) {
|
||||
handleAskMessage(ts, ask, text, partial)
|
||||
}
|
||||
} else if (msg.type === "fileSearchResults") {
|
||||
// Handle file search results from extension
|
||||
const results = (msg.results as FileSearchResult[]) || []
|
||||
setFileSearchResults(results)
|
||||
if (results.length > 0) {
|
||||
setFilePickerOpen(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSayMessage, handleAskMessage],
|
||||
[handleSayMessage, handleAskMessage, setFileSearchResults, setFilePickerOpen],
|
||||
)
|
||||
|
||||
// Initialize extension host
|
||||
|
|
@ -630,6 +650,29 @@ export function App({
|
|||
}
|
||||
})
|
||||
|
||||
// File picker handlers
|
||||
const handleFileSearch = useCallback((query: string) => {
|
||||
if (!hostRef.current) return
|
||||
// Send searchFiles message to extension
|
||||
hostRef.current.sendToExtension({
|
||||
type: "searchFiles",
|
||||
query,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(_result: FileSearchResult) => {
|
||||
// File selection is handled by FilePickerInput.
|
||||
// It will update the text input directly.
|
||||
clearFilePicker()
|
||||
},
|
||||
[clearFilePicker],
|
||||
)
|
||||
|
||||
const handleFilePickerClose = useCallback(() => {
|
||||
clearFilePicker()
|
||||
}, [clearFilePicker])
|
||||
|
||||
// Error display
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -734,28 +777,64 @@ export function App({
|
|||
<HorizontalLine />
|
||||
<Box>
|
||||
<Text color={theme.promptColor}>> </Text>
|
||||
<HistoryTextInput
|
||||
<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>
|
||||
<HorizontalLine />
|
||||
{statusBarMessage}
|
||||
{!isFilePickerOpen && statusBarMessage}
|
||||
{isFilePickerOpen && (
|
||||
<FilePickerSelect
|
||||
results={fileSearchResults}
|
||||
selectedIndex={filePickerSelectedIndex}
|
||||
maxVisible={10}
|
||||
onSelect={handleFileSelect}
|
||||
onEscape={handleFilePickerClose}
|
||||
onIndexChange={setFilePickerSelectedIndex}
|
||||
isActive={view === "UserInput" && isFilePickerOpen}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
<HorizontalLine />
|
||||
<Box>
|
||||
<Text color={theme.promptColor}>› </Text>
|
||||
<HistoryTextInput
|
||||
<FilePickerInput
|
||||
placeholder=""
|
||||
onSubmit={handleSubmit}
|
||||
isActive={view === "UserInput"}
|
||||
onFileSearch={handleFileSearch}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFilePickerClose={handleFilePickerClose}
|
||||
fileSearchResults={fileSearchResults}
|
||||
isFilePickerOpen={isFilePickerOpen}
|
||||
filePickerSelectedIndex={filePickerSelectedIndex}
|
||||
onFilePickerIndexChange={setFilePickerSelectedIndex}
|
||||
/>
|
||||
</Box>
|
||||
<HorizontalLine />
|
||||
{statusBarMessage}
|
||||
{!isFilePickerOpen && statusBarMessage}
|
||||
{isFilePickerOpen && (
|
||||
<FilePickerSelect
|
||||
results={fileSearchResults}
|
||||
selectedIndex={filePickerSelectedIndex}
|
||||
maxVisible={10}
|
||||
onSelect={handleFileSelect}
|
||||
onEscape={handleFilePickerClose}
|
||||
onIndexChange={setFilePickerSelectedIndex}
|
||||
isActive={view === "UserInput" && isFilePickerOpen}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
) : view === "ToolUse" ? (
|
||||
|
|
|
|||
202
apps/cli/src/ui/components/FilePickerInput.tsx
Normal file
202
apps/cli/src/ui/components/FilePickerInput.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useInput } from "ink"
|
||||
import { TextInput } from "@inkjs/ui"
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { useInputHistory } from "../hooks/useInputHistory.js"
|
||||
import type { FileSearchResult } from "../types.js"
|
||||
|
||||
export interface FilePickerInputProps {
|
||||
placeholder?: string
|
||||
onSubmit: (value: string) => void
|
||||
isActive?: boolean
|
||||
onFileSearch: (query: string) => void
|
||||
fileSearchResults: FileSearchResult[]
|
||||
isFilePickerOpen: boolean
|
||||
filePickerSelectedIndex: number
|
||||
onFileSelect: (result: FileSearchResult) => void
|
||||
onFilePickerClose: () => void
|
||||
onFilePickerIndexChange: (index: number) => void
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 150
|
||||
|
||||
export function FilePickerInput({
|
||||
placeholder = "Type your message...",
|
||||
onSubmit,
|
||||
isActive = true,
|
||||
onFileSearch,
|
||||
fileSearchResults,
|
||||
isFilePickerOpen,
|
||||
filePickerSelectedIndex,
|
||||
onFilePickerClose,
|
||||
}: 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 { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft } = useInputHistory({
|
||||
isActive: isActive && !isFilePickerOpen,
|
||||
getCurrentInput: () => currentInputRef.current,
|
||||
})
|
||||
|
||||
const [wasBrowsing, setWasBrowsing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isBrowsing && !wasBrowsing) {
|
||||
if (historyValue !== null) {
|
||||
setDisplayValue(historyValue)
|
||||
setInputKey((k) => k + 1)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
setWasBrowsing(isBrowsing)
|
||||
}, [isBrowsing, wasBrowsing, historyValue, draft, displayValue])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkForAtTrigger = useCallback(
|
||||
(value: string) => {
|
||||
const atIndex = value.lastIndexOf("@")
|
||||
|
||||
if (atIndex === -1) {
|
||||
if (isFilePickerOpen) {
|
||||
onFilePickerClose()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const query = value.substring(atIndex + 1)
|
||||
|
||||
if (query.includes(" ")) {
|
||||
if (isFilePickerOpen) {
|
||||
onFilePickerClose()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (query.length === 0) {
|
||||
if (isFilePickerOpen) {
|
||||
onFilePickerClose()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
if (query !== lastSearchQueryRef.current) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
lastSearchQueryRef.current = query
|
||||
onFileSearch(query)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
},
|
||||
[isFilePickerOpen, onFilePickerClose, onFileSearch],
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
currentInputRef.current = value
|
||||
|
||||
checkForAtTrigger(value)
|
||||
|
||||
if (!isBrowsing) {
|
||||
setDraft(value)
|
||||
}
|
||||
},
|
||||
[checkForAtTrigger, isBrowsing, setDraft],
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(result: FileSearchResult) => {
|
||||
const currentValue = currentInputRef.current
|
||||
const atIndex = currentValue.lastIndexOf("@")
|
||||
|
||||
if (atIndex !== -1) {
|
||||
const beforeAt = currentValue.substring(0, atIndex)
|
||||
const newValue = `${beforeAt}@/${result.path} `
|
||||
|
||||
currentInputRef.current = newValue
|
||||
setDisplayValue(newValue)
|
||||
setInputKey((k) => k + 1)
|
||||
setDraft(newValue)
|
||||
|
||||
lastSearchQueryRef.current = null
|
||||
onFilePickerClose()
|
||||
}
|
||||
},
|
||||
[onFilePickerClose, setDraft],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFilePickerOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
await addEntry(trimmed)
|
||||
|
||||
resetBrowsing("")
|
||||
currentInputRef.current = ""
|
||||
lastSearchQueryRef.current = null
|
||||
setDisplayValue("")
|
||||
setInputKey((k) => k + 1)
|
||||
|
||||
onSubmit(trimmed)
|
||||
},
|
||||
[isFilePickerOpen, addEntry, resetBrowsing, onSubmit],
|
||||
)
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!isActive || !isFilePickerOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const selected = fileSearchResults[filePickerSelectedIndex]
|
||||
|
||||
if (selected) {
|
||||
handleFileSelect(selected)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isActive && isFilePickerOpen },
|
||||
)
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
key={`file-picker-input-${inputKey}-${history.length}`}
|
||||
defaultValue={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
108
apps/cli/src/ui/components/FilePickerSelect.tsx
Normal file
108
apps/cli/src/ui/components/FilePickerSelect.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useMemo } from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
|
||||
import type { FileSearchResult } from "../types.js"
|
||||
|
||||
export interface FilePickerSelectProps {
|
||||
results: FileSearchResult[]
|
||||
selectedIndex: number
|
||||
maxVisible?: number
|
||||
onSelect: (result: FileSearchResult) => void
|
||||
onEscape: () => void
|
||||
onIndexChange: (index: number) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export function FilePickerSelect({
|
||||
results,
|
||||
selectedIndex,
|
||||
maxVisible = 10,
|
||||
onSelect,
|
||||
onEscape,
|
||||
onIndexChange,
|
||||
isActive = true,
|
||||
}: FilePickerSelectProps) {
|
||||
// Calculate the scroll window.
|
||||
const { visibleResults, scrollOffset } = useMemo(() => {
|
||||
if (results.length <= maxVisible) {
|
||||
return { visibleResults: results, scrollOffset: 0 }
|
||||
}
|
||||
|
||||
// Calculate scroll offset to keep selected item visible.
|
||||
let offset = 0
|
||||
|
||||
if (selectedIndex >= maxVisible) {
|
||||
// Need to scroll down.
|
||||
offset = Math.min(selectedIndex - maxVisible + 1, results.length - maxVisible)
|
||||
}
|
||||
|
||||
// Keep selected item in the middle when possible.
|
||||
const idealOffset = Math.max(0, selectedIndex - Math.floor(maxVisible / 2))
|
||||
offset = Math.min(idealOffset, results.length - maxVisible)
|
||||
|
||||
return {
|
||||
visibleResults: results.slice(offset, offset + maxVisible),
|
||||
scrollOffset: offset,
|
||||
}
|
||||
}, [results, selectedIndex, maxVisible])
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onEscape()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const selected = results[selectedIndex]
|
||||
|
||||
if (selected) {
|
||||
onSelect(selected)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : results.length - 1
|
||||
onIndexChange(newIndex)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
const newIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : 0
|
||||
onIndexChange(newIndex)
|
||||
return
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>No matching files found</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{visibleResults.map((result, visibleIndex) => {
|
||||
const actualIndex = scrollOffset + visibleIndex
|
||||
const isSelected = actualIndex === selectedIndex
|
||||
const displayPath = result.type === "folder" ? `${result.path}/` : result.path
|
||||
|
||||
return (
|
||||
<Box key={result.path} paddingLeft={2}>
|
||||
<Text color={isSelected ? "cyan" : undefined}>{displayPath}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
/**
|
||||
* HistoryTextInput Component
|
||||
*
|
||||
* A TextInput wrapper that provides history navigation with up/down arrow keys.
|
||||
* Uses a key-based remount strategy to work with @inkjs/ui's uncontrolled TextInput.
|
||||
*/
|
||||
|
||||
import { Box } from "ink"
|
||||
import { TextInput } from "@inkjs/ui"
|
||||
import { useCallback, useState, useEffect, useRef } from "react"
|
||||
|
||||
import { useInputHistory } from "../hooks/useInputHistory.js"
|
||||
|
||||
export interface HistoryTextInputProps {
|
||||
/** Placeholder text when input is empty */
|
||||
placeholder?: string
|
||||
/** Called when user submits input */
|
||||
onSubmit: (value: string) => void
|
||||
/** Whether history navigation is active */
|
||||
isActive?: boolean
|
||||
/** Whether to add submitted values to history */
|
||||
addToHistory?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput with history navigation support.
|
||||
*
|
||||
* - Up arrow: Navigate to older history entries (saves current input as draft)
|
||||
* - Down arrow: Navigate to newer entries or return to draft
|
||||
* - History persists to ~/.roo/cli-history.json
|
||||
*/
|
||||
export function HistoryTextInput({
|
||||
placeholder = "Type your message...",
|
||||
onSubmit,
|
||||
isActive = true,
|
||||
addToHistory = true,
|
||||
}: HistoryTextInputProps) {
|
||||
// Track the current input value via onChange
|
||||
const currentInputRef = useRef("")
|
||||
|
||||
const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft } = useInputHistory({
|
||||
isActive,
|
||||
getCurrentInput: () => currentInputRef.current,
|
||||
})
|
||||
|
||||
// Track previous browsing state to detect when we return from browsing
|
||||
const [wasBrowsing, setWasBrowsing] = useState(false)
|
||||
|
||||
// Use a key to force remount when we need to change the defaultValue
|
||||
const [inputKey, setInputKey] = useState(0)
|
||||
|
||||
// Track what value we're currently showing
|
||||
const [displayValue, setDisplayValue] = useState("")
|
||||
|
||||
// Handle changes when entering or exiting history browsing
|
||||
useEffect(() => {
|
||||
if (isBrowsing && !wasBrowsing) {
|
||||
// Just started browsing - show history value
|
||||
if (historyValue !== null) {
|
||||
setDisplayValue(historyValue)
|
||||
setInputKey((k) => k + 1)
|
||||
}
|
||||
} else if (!isBrowsing && wasBrowsing) {
|
||||
// Just stopped browsing - restore draft
|
||||
setDisplayValue(draft)
|
||||
setInputKey((k) => k + 1)
|
||||
// Reset the current input ref to draft
|
||||
currentInputRef.current = draft
|
||||
} else if (isBrowsing && historyValue !== null && historyValue !== displayValue) {
|
||||
// Navigating within history
|
||||
setDisplayValue(historyValue)
|
||||
setInputKey((k) => k + 1)
|
||||
}
|
||||
setWasBrowsing(isBrowsing)
|
||||
}, [isBrowsing, wasBrowsing, historyValue, draft, displayValue])
|
||||
|
||||
// Handle input changes
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
currentInputRef.current = value
|
||||
// Also update draft if we're not browsing
|
||||
if (!isBrowsing) {
|
||||
setDraft(value)
|
||||
}
|
||||
},
|
||||
[isBrowsing, setDraft],
|
||||
)
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
async (text: string) => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
// Add to history if enabled
|
||||
if (addToHistory) {
|
||||
await addEntry(trimmed)
|
||||
}
|
||||
|
||||
// Reset browsing state and clear draft
|
||||
resetBrowsing("")
|
||||
currentInputRef.current = ""
|
||||
setDisplayValue("")
|
||||
setInputKey((k) => k + 1)
|
||||
|
||||
// Call parent submit handler
|
||||
onSubmit(trimmed)
|
||||
},
|
||||
[addToHistory, addEntry, resetBrowsing, onSubmit],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextInput
|
||||
key={`history-input-${inputKey}-${history.length}`}
|
||||
defaultValue={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryTextInput
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
import type { TUIMessage, PendingAsk } from "./types.js"
|
||||
import type { TUIMessage, PendingAsk, FileSearchResult } from "./types.js"
|
||||
|
||||
interface CLIState {
|
||||
messages: TUIMessage[]
|
||||
|
|
@ -9,6 +9,10 @@ interface CLIState {
|
|||
isComplete: boolean
|
||||
hasStartedTask: boolean
|
||||
error: string | null
|
||||
fileSearchResults: FileSearchResult[]
|
||||
isFilePickerOpen: boolean
|
||||
filePickerQuery: string
|
||||
filePickerSelectedIndex: number
|
||||
}
|
||||
|
||||
interface CLIActions {
|
||||
|
|
@ -20,6 +24,11 @@ interface CLIActions {
|
|||
setHasStartedTask: (started: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
reset: () => void
|
||||
setFileSearchResults: (results: FileSearchResult[]) => void
|
||||
setFilePickerOpen: (open: boolean) => void
|
||||
setFilePickerQuery: (query: string) => void
|
||||
setFilePickerSelectedIndex: (index: number) => void
|
||||
clearFilePicker: () => void
|
||||
}
|
||||
|
||||
const initialState: CLIState = {
|
||||
|
|
@ -29,6 +38,10 @@ const initialState: CLIState = {
|
|||
isComplete: false,
|
||||
hasStartedTask: false,
|
||||
error: null,
|
||||
fileSearchResults: [],
|
||||
isFilePickerOpen: false,
|
||||
filePickerQuery: "",
|
||||
filePickerSelectedIndex: 0,
|
||||
}
|
||||
|
||||
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
|
||||
|
|
@ -81,4 +94,15 @@ export const useCLIStore = create<CLIState & CLIActions>((set) => ({
|
|||
setHasStartedTask: (started) => set({ hasStartedTask: started }),
|
||||
setError: (error) => set({ error }),
|
||||
reset: () => set(initialState),
|
||||
setFileSearchResults: (results) => set({ fileSearchResults: results, filePickerSelectedIndex: 0 }),
|
||||
setFilePickerOpen: (open) => set({ isFilePickerOpen: open }),
|
||||
setFilePickerQuery: (query) => set({ filePickerQuery: query }),
|
||||
setFilePickerSelectedIndex: (index) => set({ filePickerSelectedIndex: index }),
|
||||
clearFilePicker: () =>
|
||||
set({
|
||||
fileSearchResults: [],
|
||||
isFilePickerOpen: false,
|
||||
filePickerQuery: "",
|
||||
filePickerSelectedIndex: 0,
|
||||
}),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { ClineAsk, ClineSay } from "@roo-code/types"
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
|
||||
|
||||
/**
|
||||
* Ask types that require user input.
|
||||
*/
|
||||
export type AskType =
|
||||
export type AskType = Extract<
|
||||
ClineAsk,
|
||||
| "followup"
|
||||
| "command"
|
||||
| "command_output"
|
||||
|
|
@ -14,25 +14,23 @@ export type AskType =
|
|||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
| "completion_result"
|
||||
>
|
||||
|
||||
/**
|
||||
* Say types for display-only messages.
|
||||
*/
|
||||
export type SayType =
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| Extract<
|
||||
ClineSay,
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "error"
|
||||
| "api_req_started"
|
||||
| "user_feedback"
|
||||
| "checkpoint_saved"
|
||||
>
|
||||
| "thinking"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "error"
|
||||
| "tool"
|
||||
| "api_req_started"
|
||||
| "user_feedback"
|
||||
| "checkpoint_saved"
|
||||
|
||||
/**
|
||||
* A message displayed in the TUI message list.
|
||||
*/
|
||||
export interface TUIMessage {
|
||||
id: string
|
||||
role: MessageRole
|
||||
|
|
@ -45,9 +43,6 @@ export interface TUIMessage {
|
|||
originalType?: SayType | AskType
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending ask that requires user response.
|
||||
*/
|
||||
export interface PendingAsk {
|
||||
id: string
|
||||
type: AskType
|
||||
|
|
@ -71,3 +66,9 @@ export interface AppProps {
|
|||
}
|
||||
|
||||
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
|
||||
|
||||
export interface FileSearchResult {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
label?: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue