Add file picker

This commit is contained in:
cte 2026-01-06 02:59:57 -08:00
parent c137cf44c8
commit ad6ce88583
8 changed files with 918 additions and 156 deletions

View 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)
})
})
})

View 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" })
})
})
})

View 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}>&gt; </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" ? (

View 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}
/>
)
}

View 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>
)
}

View file

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

View file

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

View file

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