More progress

This commit is contained in:
cte 2026-01-06 15:39:45 -08:00
parent 0a8f1a13c4
commit 262aaa52d1
35 changed files with 2903 additions and 1373 deletions

View file

@ -71,7 +71,13 @@ By default, the CLI prompts for approval before executing actions:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
roo "What is this project?" --workspace ~/Documents/my-project
roo ~/Documents/my-project -P "What is this project?"
```
You can also run without a prompt and enter it interactively in TUI mode:
```bash
roo ~/Documents/my-project
```
In interactive mode:
@ -86,7 +92,7 @@ In interactive mode:
For automation and scripts, use `-y` to auto-approve all actions:
```bash
roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project
roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
```
In non-interactive mode:
@ -99,7 +105,8 @@ In non-interactive mode:
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |

View file

@ -260,7 +260,7 @@ print_success() {
echo ""
echo " ${BOLD}Example:${NC}"
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
echo " roo \"What is this project?\" --workspace ~/my-project"
echo " roo ~/my-project -P \"What is this project?\""
echo ""
}

View file

@ -24,6 +24,7 @@
"@roo-code/vscode-shim": "workspace:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0",
"fuzzysort": "^3.1.0",
"ink": "^6.6.0",
"react": "^19.1.0",
"zustand": "^5.0.0"

View file

@ -1,255 +0,0 @@
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

@ -1,218 +0,0 @@
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

@ -0,0 +1,472 @@
/**
* Unit tests for ScrollArea component reducer logic
*/
// Since we can't easily test React components without a proper Ink test setup,
// we'll test the reducer logic that powers the ScrollArea behavior.
interface ScrollAreaState {
innerHeight: number
height: number
scrollTop: number
autoScroll: boolean
}
/**
* Calculate scrollbar handle position and size
*/
function calculateScrollbar(
viewportHeight: number,
contentHeight: number,
scrollTop: number,
): { handleStart: number; handleHeight: number; maxScroll: number } {
const maxScroll = Math.max(0, contentHeight - viewportHeight)
if (contentHeight <= viewportHeight || maxScroll === 0) {
// No scrolling needed - handle fills entire track
return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 }
}
// Calculate handle height as ratio of viewport to content (minimum 1 line)
const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight))
// Calculate handle position
const trackSpace = viewportHeight - handleHeight
const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0
const handleStart = Math.round(scrollRatio * trackSpace)
return { handleStart, handleHeight, maxScroll }
}
type ScrollAreaAction =
| { type: "SET_INNER_HEIGHT"; innerHeight: number }
| { type: "SET_HEIGHT"; height: number }
| { type: "SCROLL_DOWN"; amount?: number }
| { type: "SCROLL_UP"; amount?: number }
| { type: "SCROLL_TO_BOTTOM" }
| { type: "SET_AUTO_SCROLL"; autoScroll: boolean }
// Copy of the reducer from ScrollArea.tsx for testing
function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState {
const maxScroll = Math.max(0, state.innerHeight - state.height)
switch (action.type) {
case "SET_INNER_HEIGHT": {
const newMaxScroll = Math.max(0, action.innerHeight - state.height)
if (state.autoScroll && action.innerHeight > state.innerHeight) {
return {
...state,
innerHeight: action.innerHeight,
scrollTop: newMaxScroll,
}
}
return {
...state,
innerHeight: action.innerHeight,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SET_HEIGHT": {
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
if (state.autoScroll) {
return {
...state,
height: action.height,
scrollTop: newMaxScroll,
}
}
return {
...state,
height: action.height,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SCROLL_DOWN": {
const amount = action.amount || 1
const newScrollTop = Math.min(maxScroll, state.scrollTop + amount)
const atBottom = newScrollTop >= maxScroll
return {
...state,
scrollTop: newScrollTop,
autoScroll: atBottom,
}
}
case "SCROLL_UP": {
const amount = action.amount || 1
const newScrollTop = Math.max(0, state.scrollTop - amount)
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
case "SCROLL_TO_BOTTOM":
return {
...state,
scrollTop: maxScroll,
autoScroll: true,
}
case "SET_AUTO_SCROLL":
return {
...state,
autoScroll: action.autoScroll,
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
}
default:
return state
}
}
describe("ScrollArea reducer", () => {
const initialState: ScrollAreaState = {
innerHeight: 0,
height: 10,
scrollTop: 0,
autoScroll: true,
}
describe("SET_INNER_HEIGHT", () => {
it("should update inner height", () => {
const state = reducer(initialState, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
expect(state.innerHeight).toBe(20)
})
it("should auto-scroll to bottom when content grows and autoScroll is enabled", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 15,
autoScroll: true,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
expect(newState.innerHeight).toBe(25)
// maxScroll = 25 - 10 = 15
expect(newState.scrollTop).toBe(15)
})
it("should NOT auto-scroll when autoScroll is disabled", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 15,
scrollTop: 3,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
expect(newState.innerHeight).toBe(25)
expect(newState.scrollTop).toBe(3) // Unchanged
})
it("should clamp scrollTop when content shrinks", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 30,
scrollTop: 15,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 15 })
// maxScroll = 15 - 10 = 5, scrollTop was 15 which is > 5
expect(newState.scrollTop).toBe(5)
})
})
describe("SET_HEIGHT", () => {
it("should update viewport height", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 20,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
expect(newState.height).toBe(15)
})
it("should scroll to bottom when autoScroll is enabled and viewport changes", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20, // at bottom
autoScroll: true,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
// maxScroll = 30 - 15 = 15
expect(newState.scrollTop).toBe(15)
})
it("should clamp scrollTop when viewport grows", () => {
const state: ScrollAreaState = {
innerHeight: 20,
height: 10,
scrollTop: 10, // maxScroll was 10
autoScroll: false,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
// maxScroll = 20 - 15 = 5
expect(newState.scrollTop).toBe(5)
})
})
describe("SCROLL_DOWN", () => {
it("should scroll down by 1 by default", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(6)
})
it("should scroll down by specified amount", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 5 })
expect(newState.scrollTop).toBe(10)
})
it("should not scroll past maxScroll", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 18,
autoScroll: false,
}
// maxScroll = 30 - 10 = 20
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 10 })
expect(newState.scrollTop).toBe(20)
})
it("should re-enable autoScroll when reaching bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 19,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(20)
expect(newState.autoScroll).toBe(true)
})
})
describe("SCROLL_UP", () => {
it("should scroll up by 1 by default", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 10,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP" })
expect(newState.scrollTop).toBe(9)
})
it("should scroll up by specified amount", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 10,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP", amount: 5 })
expect(newState.scrollTop).toBe(5)
})
it("should not scroll past 0", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 3,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP", amount: 10 })
expect(newState.scrollTop).toBe(0)
})
it("should disable autoScroll when scrolling up from bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20, // at bottom
autoScroll: true,
}
const newState = reducer(state, { type: "SCROLL_UP" })
expect(newState.scrollTop).toBe(19)
expect(newState.autoScroll).toBe(false)
})
})
describe("SCROLL_TO_BOTTOM", () => {
it("should scroll to bottom and enable autoScroll", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
expect(newState.scrollTop).toBe(20) // maxScroll
expect(newState.autoScroll).toBe(true)
})
})
describe("SET_AUTO_SCROLL", () => {
it("should enable autoScroll and scroll to bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: true })
expect(newState.autoScroll).toBe(true)
expect(newState.scrollTop).toBe(20) // scrolled to bottom
})
it("should disable autoScroll without changing scrollTop", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20,
autoScroll: true,
}
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: false })
expect(newState.autoScroll).toBe(false)
expect(newState.scrollTop).toBe(20)
})
})
describe("edge cases", () => {
it("should handle content smaller than viewport", () => {
const state: ScrollAreaState = {
innerHeight: 5, // smaller than viewport
height: 10,
scrollTop: 0,
autoScroll: true,
}
const downState = reducer(state, { type: "SCROLL_DOWN" })
expect(downState.scrollTop).toBe(0) // maxScroll is 0
const bottomState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
expect(bottomState.scrollTop).toBe(0)
})
it("should handle empty content", () => {
const state: ScrollAreaState = {
innerHeight: 0,
height: 10,
scrollTop: 0,
autoScroll: true,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(0)
})
})
})
describe("calculateScrollbar", () => {
it("should return full height handle when content fits in viewport", () => {
const result = calculateScrollbar(10, 5, 0)
expect(result.handleHeight).toBe(10)
expect(result.handleStart).toBe(0)
expect(result.maxScroll).toBe(0)
})
it("should return full height handle when content equals viewport", () => {
const result = calculateScrollbar(10, 10, 0)
expect(result.handleHeight).toBe(10)
expect(result.handleStart).toBe(0)
expect(result.maxScroll).toBe(0)
})
it("should calculate handle height proportional to content ratio", () => {
// Viewport is half of content, handle should be ~half of viewport
const result = calculateScrollbar(10, 20, 0)
expect(result.handleHeight).toBe(5) // 10 / 20 * 10 = 5
expect(result.maxScroll).toBe(10)
})
it("should position handle at top when scrollTop is 0", () => {
const result = calculateScrollbar(10, 20, 0)
expect(result.handleStart).toBe(0)
})
it("should position handle at bottom when scrolled to max", () => {
// Viewport 10, content 20, maxScroll = 10
// Handle height = 5, track space = 10 - 5 = 5
// At max scroll, handle should be at position 5
const result = calculateScrollbar(10, 20, 10)
expect(result.handleStart).toBe(5)
})
it("should position handle in middle when scrolled halfway", () => {
// Viewport 10, content 20, maxScroll = 10
// Handle height = 5, track space = 5
// At scroll 5 (50%), handle should be at position 2-3
const result = calculateScrollbar(10, 20, 5)
expect(result.handleStart).toBe(3) // Math.round(0.5 * 5) = 3
})
it("should enforce minimum handle height of 1", () => {
// Very large content relative to viewport
const result = calculateScrollbar(10, 1000, 0)
expect(result.handleHeight).toBe(1) // Math.max(1, Math.round(10/1000 * 10)) = 1
})
it("should handle small viewports", () => {
const result = calculateScrollbar(3, 10, 0)
expect(result.handleHeight).toBe(1) // Math.round(3/10 * 3) = 1
expect(result.maxScroll).toBe(7)
})
it("should handle edge case where scrollTop exceeds maxScroll", () => {
// This shouldn't happen in practice, but test for robustness
const result = calculateScrollbar(10, 20, 15) // maxScroll is 10
// scrollRatio = 15/10 = 1.5, but handleStart should be clamped by trackSpace
expect(result.handleStart).toBe(8) // Math.round(1.5 * 5) = 8 (will be past track but shows calculation)
})
})
/**
* Helper function that mirrors the scrollbar visibility logic from ScrollArea.tsx
* This is used to test the visibility behavior without needing to render the component.
*/
function shouldShowScrollbar(showScrollbar: boolean, maxScroll: number, isActive: boolean): boolean {
// Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state)
// Hide scrollbar only when: not focused AND nothing to scroll
return showScrollbar && (maxScroll > 0 || isActive)
}
describe("scrollbar visibility", () => {
it("should show scrollbar when there is content to scroll (regardless of focus)", () => {
// When maxScroll > 0, scrollbar should show regardless of isActive
expect(shouldShowScrollbar(true, 10, true)).toBe(true)
expect(shouldShowScrollbar(true, 10, false)).toBe(true)
})
it("should show scrollbar when focused, even if nothing to scroll", () => {
// When isActive is true but maxScroll is 0, scrollbar should show for focus indication
expect(shouldShowScrollbar(true, 0, true)).toBe(true)
})
it("should hide scrollbar when not focused and nothing to scroll", () => {
// Only hide when both: not focused AND nothing to scroll
expect(shouldShowScrollbar(true, 0, false)).toBe(false)
})
it("should respect showScrollbar prop", () => {
// When showScrollbar is false, never show scrollbar
expect(shouldShowScrollbar(false, 10, true)).toBe(false)
expect(shouldShowScrollbar(false, 0, true)).toBe(false)
expect(shouldShowScrollbar(false, 10, false)).toBe(false)
expect(shouldShowScrollbar(false, 0, false)).toBe(false)
})
})

View file

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from "vitest"
import {
createFileTrigger,
toFileResult,
type FileResult,
} from "../../ui/components/autocomplete/triggers/FileTrigger.js"
describe("FileTrigger", () => {
describe("toFileResult", () => {
it("should convert FileSearchResult to FileResult with key", () => {
const input = { path: "src/test.ts", type: "file" as const }
const result = toFileResult(input)
expect(result).toEqual({
key: "src/test.ts",
path: "src/test.ts",
type: "file",
label: undefined,
})
})
it("should include label if provided", () => {
const input = { path: "src/", type: "folder" as const, label: "Source" }
const result = toFileResult(input)
expect(result).toEqual({
key: "src/",
path: "src/",
type: "folder",
label: "Source",
})
})
})
describe("detectTrigger", () => {
const onSearch = vi.fn()
const getResults = (): FileResult[] => []
const trigger = createFileTrigger({ onSearch, getResults })
it("should detect @ trigger with query", () => {
const result = trigger.detectTrigger("hello @test")
expect(result).toEqual({
query: "test",
triggerIndex: 6,
})
})
it("should return null when no @ present", () => {
const result = trigger.detectTrigger("hello world")
expect(result).toBeNull()
})
it("should return null when query contains space", () => {
const result = trigger.detectTrigger("hello @test file")
expect(result).toBeNull()
})
it("should return null when query is empty", () => {
const result = trigger.detectTrigger("hello @")
expect(result).toBeNull()
})
it("should find last @ in line", () => {
const result = trigger.detectTrigger("email@test.com @file")
expect(result).toEqual({
query: "file",
triggerIndex: 15,
})
})
})
describe("getReplacementText", () => {
const onSearch = vi.fn()
const getResults = (): FileResult[] => []
const trigger = createFileTrigger({ onSearch, getResults })
it("should replace @ trigger with file path", () => {
const item: FileResult = { key: "src/test.ts", path: "src/test.ts", type: "file" }
const result = trigger.getReplacementText(item, "hello @tes", 6)
expect(result).toBe("hello @/src/test.ts ")
})
it("should preserve text before @", () => {
const item: FileResult = { key: "config.json", path: "config.json", type: "file" }
const result = trigger.getReplacementText(item, "check @co", 6)
expect(result).toBe("check @/config.json ")
})
})
describe("search", () => {
it("should call onSearch and return current results", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [{ key: "test.ts", path: "test.ts", type: "file" }]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.search("test")
expect(onSearch).toHaveBeenCalledWith("test")
expect(getResults).toHaveBeenCalled()
expect(result).toBe(mockResults)
})
})
})

View file

@ -0,0 +1,161 @@
import { describe, it, expect, vi } from "vitest"
import {
createSlashCommandTrigger,
toSlashCommandResult,
type SlashCommandResult,
} from "../../ui/components/autocomplete/triggers/SlashCommandTrigger.js"
describe("SlashCommandTrigger", () => {
describe("toSlashCommandResult", () => {
it("should convert command to SlashCommandResult with key", () => {
const input = {
name: "test",
description: "A test command",
source: "built-in" as const,
}
const result = toSlashCommandResult(input)
expect(result).toEqual({
key: "test",
name: "test",
description: "A test command",
argumentHint: undefined,
source: "built-in",
})
})
it("should include argumentHint if provided", () => {
const input = {
name: "mode",
description: "Switch mode",
argumentHint: "<mode-name>",
source: "project" as const,
}
const result = toSlashCommandResult(input)
expect(result).toEqual({
key: "mode",
name: "mode",
description: "Switch mode",
argumentHint: "<mode-name>",
source: "project",
})
})
})
describe("detectTrigger", () => {
const getCommands = (): SlashCommandResult[] => []
const trigger = createSlashCommandTrigger({ getCommands })
it("should detect / at line start", () => {
const result = trigger.detectTrigger("/test")
expect(result).toEqual({
query: "test",
triggerIndex: 0,
})
})
it("should detect / with leading whitespace", () => {
const result = trigger.detectTrigger(" /test")
expect(result).toEqual({
query: "test",
triggerIndex: 2,
})
})
it("should return query with empty string for just /", () => {
const result = trigger.detectTrigger("/")
expect(result).toEqual({
query: "",
triggerIndex: 0,
})
})
it("should return null when / not at line start", () => {
const result = trigger.detectTrigger("hello /test")
expect(result).toBeNull()
})
it("should return null when query contains space", () => {
const result = trigger.detectTrigger("/test command")
expect(result).toBeNull()
})
})
describe("getReplacementText", () => {
const getCommands = (): SlashCommandResult[] => []
const trigger = createSlashCommandTrigger({ getCommands })
it("should replace / trigger with command name", () => {
const item: SlashCommandResult = {
key: "test",
name: "test",
source: "built-in",
}
const result = trigger.getReplacementText(item, "/tes", 0)
expect(result).toBe("/test ")
})
it("should preserve leading whitespace", () => {
const item: SlashCommandResult = {
key: "mode",
name: "mode",
source: "project",
}
const result = trigger.getReplacementText(item, " /mo", 2)
expect(result).toBe(" /mode ")
})
})
describe("search", () => {
it("should return all commands when query is empty", async () => {
const mockCommands: SlashCommandResult[] = [
{ key: "test", name: "test", source: "built-in" },
{ key: "mode", name: "mode", source: "project" },
]
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands })
const result = await trigger.search("")
expect(result).toEqual(mockCommands)
})
it("should fuzzy search commands by name", async () => {
const mockCommands: SlashCommandResult[] = [
{ key: "test", name: "test", source: "built-in" },
{ key: "mode", name: "mode", source: "project" },
{ key: "help", name: "help", source: "built-in" },
]
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands })
const result = await trigger.search("mod")
// Should prioritize "mode" since it matches best
expect(result.length).toBeGreaterThan(0)
expect(result[0]?.name).toBe("mode")
})
it("should respect maxResults option", async () => {
const mockCommands: SlashCommandResult[] = Array.from({ length: 30 }, (_, i) => ({
key: `cmd${i}`,
name: `cmd${i}`,
source: "built-in" as const,
}))
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands, maxResults: 5 })
const result = await trigger.search("")
expect(result).toHaveLength(5)
})
})
})

View file

@ -42,8 +42,8 @@ program
.version(packageJson.version)
program
.argument("[prompt]", "The prompt/task to execute (optional in TUI mode)")
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
.argument("[workspace]", "Workspace path to operate in", process.cwd())
.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
@ -61,9 +61,9 @@ program
.option("--no-tui", "Disable TUI, use plain text output")
.action(
async (
prompt: string | undefined,
workspaceArg: string,
options: {
workspace: string
prompt?: string
extension?: string
verbose: boolean
debug: boolean
@ -90,7 +90,7 @@ program
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(options.workspace)
const workspacePath = path.resolve(workspaceArg)
if (!apiKey) {
console.error(
@ -127,9 +127,9 @@ program
}
// In plain text mode, prompt is required
if (!useTui && !prompt) {
if (!useTui && !options.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo <prompt> [options]")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
process.exit(1)
}
@ -137,9 +137,6 @@ program
if (useTui) {
// TUI Mode - render Ink application
try {
// Clear screen before Ink starts
process.stdout.write("\x1B[2J\x1B[0;0H")
const { render } = await import("ink")
const { App } = await import("./ui/App.js")
@ -177,7 +174,7 @@ program
render(
createElement(App, {
initialPrompt: prompt || "", // Empty string if no prompt - user will type in TUI
initialPrompt: options.prompt || "", // Empty string if no prompt - user will type in TUI
workspacePath: workspacePath,
extensionPath: path.resolve(extensionPath),
apiProvider: options.provider,
@ -190,6 +187,7 @@ program
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
createExtensionHost: createExtensionHost,
version: packageJson.version,
}),
{
exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit
@ -239,7 +237,7 @@ program
try {
await host.activate()
await host.runTask(prompt!) // prompt is guaranteed non-null in plain text mode
await host.runTask(options.prompt!) // prompt is guaranteed non-null in plain text mode
await host.dispose()
if (options.exitOnComplete) {

View file

@ -8,11 +8,36 @@ import { useCLIStore } from "./store.js"
import Header from "./components/Header.js"
import ChatHistoryItem from "./components/ChatHistoryItem.js"
import LoadingText from "./components/LoadingText.js"
import { FilePickerInput } from "./components/FilePickerInput.js"
import { FilePickerSelect } from "./components/FilePickerSelect.js"
import { useTerminalSize } from "./hooks/useTerminalSize.js"
import {
AutocompleteInput,
PickerSelect,
createFileTrigger,
createSlashCommandTrigger,
toFileResult,
toSlashCommandResult,
type AutocompleteInputHandle,
type AutocompletePickerState,
type AutocompleteTrigger,
type FileResult,
type SlashCommandResult as SlashCommandItem,
} from "./components/autocomplete/index.js"
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
import ScrollIndicator from "./components/ScrollIndicator.js"
import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js"
import * as theme from "./utils/theme.js"
import type { AppProps, TUIMessage, PendingAsk, SayType, AskType, View, FileSearchResult } from "./types.js"
import type {
AppProps,
TUIMessage,
PendingAsk,
SayType,
AskType,
View,
FileSearchResult,
SlashCommandResult,
} from "./types.js"
// Layout constants
const PICKER_HEIGHT = 10 // Max height for picker when open
/**
* Interface for the extension host that the TUI interacts with
@ -95,17 +120,18 @@ function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoadin
}
/**
* Full-width horizontal line component - responsive to terminal resize
* Full-width horizontal line component - uses terminal size from context
*/
function HorizontalLine() {
function HorizontalLine({ active = false }: { active?: boolean }) {
const { columns } = useTerminalSize()
return <Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
const color = active ? theme.borderColorActive : theme.borderColor
return <Text color={color}>{"─".repeat(columns)}</Text>
}
/**
* Main TUI Application Component
* Inner App component that uses the terminal size context
*/
export function App({
function AppInner({
initialPrompt,
workspacePath,
extensionPath,
@ -119,6 +145,7 @@ export function App({
exitOnComplete,
reasoningEffort,
createExtensionHost,
version,
}: TUIAppProps) {
const { exit } = useApp()
@ -136,15 +163,16 @@ export function App({
setHasStartedTask,
setError,
fileSearchResults,
isFilePickerOpen,
filePickerSelectedIndex,
allSlashCommands,
setFileSearchResults,
setFilePickerOpen,
setFilePickerSelectedIndex,
clearFilePicker,
setAllSlashCommands,
} = useCLIStore()
const hostRef = useRef<ExtensionHostInterface | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// Track seen message timestamps to filter duplicates and the prompt echo
const seenMessageIds = useRef<Set<string>>(new Set())
@ -160,15 +188,72 @@ export function App({
// Ref to track transition state (handles async state update timing)
const isTransitioningToCustomInput = useRef(false)
// Manual focus override: 'scroll' | 'input' | null (null = auto-determine)
const [manualFocus, setManualFocus] = useState<"scroll" | "input" | null>(null)
// Autocomplete picker state (received from AutocompleteInput via callback)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [pickerState, setPickerState] = useState<AutocompletePickerState<any>>({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
// Scroll area state
const { rows } = useTerminalSize()
const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
// Determine current view
const view = getView(messages, pendingAsk, isLoading)
// Determine if we should show the approval prompt (Y/N) instead of text input
const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup"
// Determine if we're in a mode where focus can be toggled (text input is available)
const canToggleFocus =
!showApprovalPrompt &&
(!pendingAsk || // Initial input or task complete or loading
pendingAsk.type === "followup" || // Followup question with suggestions or custom input
showCustomInput) // Custom input mode
// Determine if scroll area should capture keyboard input
const isScrollAreaActive: boolean =
manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt)
// Determine if input area is active (for visual focus indicator)
const isInputAreaActive: boolean =
manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt
// Reset manual focus when view changes (e.g., agent starts responding)
useEffect(() => {
if (!canToggleFocus) {
setManualFocus(null)
}
}, [canToggleFocus])
// Display all messages including partial (streaming) ones
// The store handles deduplication by ID, so partial messages get updated in place
const displayMessages = useMemo(() => {
return messages
}, [messages])
// Scroll to bottom when new messages arrive (if auto-scroll is enabled)
const prevMessageCount = useRef(messages.length)
useEffect(() => {
if (messages.length > prevMessageCount.current && scrollState.isAtBottom) {
scrollToBottom()
}
prevMessageCount.current = messages.length
}, [messages.length, scrollState.isAtBottom, scrollToBottom])
// Handle scroll state changes from ScrollArea
const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
setScrollState({ scrollTop, maxScroll, isAtBottom })
}, [])
// Cleanup function
const cleanup = useCallback(async () => {
if (hostRef.current) {
@ -177,13 +262,48 @@ export function App({
}
}, [])
// Handle Ctrl+C - close file picker first, then require double press to exit
// Using useInput to capture in raw mode (Ink intercepts SIGINT)
// File search handler for the file trigger
const handleFileSearch = useCallback((query: string) => {
if (!hostRef.current) return
hostRef.current.sendToExtension({
type: "searchFiles",
query,
})
}, [])
// Create autocomplete triggers
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
const fileTrigger = createFileTrigger({
onSearch: handleFileSearch,
getResults: () => fileSearchResults.map(toFileResult),
})
const slashCommandTrigger = createSlashCommandTrigger({
getCommands: () => allSlashCommands.map(toSlashCommandResult),
})
return [fileTrigger, slashCommandTrigger]
}, [handleFileSearch, fileSearchResults, allSlashCommands])
// Handle Ctrl+C and Tab for focus switching
useInput((input, key) => {
// Tab to toggle focus between scroll area and input (only when input is available)
if (key.tab && canToggleFocus && !pickerState.isOpen) {
setManualFocus((prev) => {
if (prev === "scroll") return "input"
if (prev === "input") return "scroll"
return isScrollAreaActive ? "input" : "scroll"
})
return
}
if (key.ctrl && input === "c") {
// If file picker is open, close it first
if (isFilePickerOpen) {
clearFilePicker()
// If picker is open, close it first
if (pickerState.isOpen) {
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
return
}
@ -201,7 +321,6 @@ export function App({
pendingExit.current = true
setShowExitHint(true)
// Clear the hint and reset after 2 seconds
exitHintTimeout.current = setTimeout(() => {
pendingExit.current = false
setShowExitHint(false)
@ -225,9 +344,6 @@ export function App({
(ts: number, say: SayType, text: string, partial: boolean) => {
const messageId = ts.toString()
// Filter out internal messages we don't want to display
// checkpoint_saved contains internal commit hashes
// api_req_started is verbose technical info
if (say === "checkpoint_saved") {
return
}
@ -235,41 +351,33 @@ export function App({
return
}
// Skip user_feedback - we already display user messages via addMessage() in handleSubmit
// The extension echoes user input as user_feedback which would cause duplicates
if (say === "user_feedback") {
seenMessageIds.current.add(messageId)
return
}
// Skip the first "text" message - the extension echoes the user's prompt
// We already display the user's message, so skip this echo
if (say === "text" && !firstTextMessageSkipped.current) {
firstTextMessageSkipped.current = true
seenMessageIds.current.add(messageId)
return
}
// Skip if we've already processed this message ID (except for streaming updates)
if (seenMessageIds.current.has(messageId) && !partial) {
return
}
// Map say type to role
let role: TUIMessage["role"] = "assistant"
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
if (say === "command_output") {
// command_output is plain text output from a bash command
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
} else if (say === "tool") {
role = "tool"
// Try to parse tool info
try {
const toolInfo = JSON.parse(text)
toolName = toolInfo.tool
@ -282,10 +390,8 @@ export function App({
role = "thinking"
}
// Track this message ID
seenMessageIds.current.add(messageId)
// For streaming updates, the store's addMessage handles updating existing messages by ID
addMessage({
id: messageId,
role,
@ -305,39 +411,29 @@ export function App({
(ts: number, ask: AskType, text: string, partial: boolean) => {
const messageId = ts.toString()
// For partial messages, just return
if (partial) {
return
}
// Skip if we've already processed this ask (e.g., already approved/rejected)
if (seenMessageIds.current.has(messageId)) {
return
}
// command_output asks are for streaming command output, not for user approval
// They should NOT trigger a Y/N prompt
if (ask === "command_output") {
seenMessageIds.current.add(messageId)
return
}
// completion_result is handled via the "taskComplete" event, not as a pending ask
// It should show the text input for follow-up, not Y/N prompt
if (ask === "completion_result") {
// Mark task as complete - user can type follow-up
seenMessageIds.current.add(messageId)
setComplete(true)
setLoading(false)
return
}
// In non-interactive mode, auto-approval is handled by extension settings
if (nonInteractive && ask !== "followup") {
// Show the action being taken
seenMessageIds.current.add(messageId)
// For tool asks, parse and format nicely
if (ask === "tool") {
let toolName: string | undefined
let toolDisplayName: string | undefined
@ -374,7 +470,6 @@ export function App({
return
}
// Parse suggestions for followup questions and format tool asks
let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
let questionText = text
@ -387,7 +482,6 @@ export function App({
// Use raw text
}
} else if (ask === "tool") {
// Parse tool JSON and format nicely
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
questionText = formatToolAskMessage(toolInfo)
@ -396,10 +490,8 @@ export function App({
}
}
// Mark as seen BEFORE setting pendingAsk to prevent re-processing
seenMessageIds.current.add(messageId)
// Set pending ask to show approval prompt
setPendingAsk({
id: messageId,
type: ask,
@ -453,15 +545,26 @@ export function App({
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)
}
} else if (msg.type === "commands") {
const commands =
(msg.commands as Array<{
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
}>) || []
const slashCommands: SlashCommandResult[] = commands.map((cmd) => ({
name: cmd.name,
description: cmd.description,
argumentHint: cmd.argumentHint,
source: cmd.source,
}))
setAllSlashCommands(slashCommands)
}
},
[handleSayMessage, handleAskMessage, setFileSearchResults, setFilePickerOpen],
[handleSayMessage, handleAskMessage, setFileSearchResults, setAllSlashCommands],
)
// Initialize extension host
@ -479,15 +582,13 @@ export function App({
verbose: debug,
quiet: !verbose && !debug,
nonInteractive,
disableOutput: true, // TUI mode - Ink handles all rendering
disableOutput: true,
})
hostRef.current = host
// Listen for extension messages
host.on("extensionWebviewMessage", handleExtensionMessage)
// Listen for task completion
host.on("taskComplete", async () => {
setComplete(true)
setLoading(false)
@ -498,21 +599,20 @@ export function App({
}
})
// Listen for errors
host.on("taskError", (err: string) => {
setError(err)
setLoading(false)
})
// Activate the extension
await host.activate()
host.sendToExtension({ type: "requestCommands" })
setLoading(false)
// Only run task automatically if we have an initial prompt
if (initialPrompt) {
setHasStartedTask(true)
setLoading(true)
// Add user message for the initial prompt
addMessage({
id: randomUUID(),
role: "user",
@ -540,20 +640,17 @@ export function App({
const trimmedText = text.trim()
// Guard: don't submit the special "__CUSTOM__" value from Select
if (trimmedText === "__CUSTOM__") {
return
}
if (pendingAsk) {
// Add user message to chat history
addMessage({
id: randomUUID(),
role: "user",
content: trimmedText,
})
// Send as response to ask
hostRef.current.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
@ -562,13 +659,11 @@ export function App({
setPendingAsk(null)
setShowCustomInput(false)
isTransitioningToCustomInput.current = false
setLoading(true) // Show "Thinking" while waiting for response
setLoading(true)
} else if (!hasStartedTask) {
// First message - start a new task
setHasStartedTask(true)
setLoading(true)
// Add user message
addMessage({
id: randomUUID(),
role: "user",
@ -582,7 +677,6 @@ export function App({
setLoading(false)
}
} else {
// Send as follow-up message (resume task if it was complete)
if (isComplete) {
setComplete(false)
}
@ -623,7 +717,7 @@ export function App({
askResponse: "yesButtonClicked",
})
setPendingAsk(null)
setLoading(true) // Show "Thinking" while waiting for response
setLoading(true)
}, [setPendingAsk, setLoading])
// Handle rejection (N key)
@ -635,7 +729,7 @@ export function App({
askResponse: "noButtonClicked",
})
setPendingAsk(null)
setLoading(true) // Show "Thinking" while waiting for response
setLoading(true)
}, [setPendingAsk, setLoading])
// Handle Y/N input for approval prompts
@ -650,28 +744,30 @@ 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,
})
// Handle picker state changes from AutocompleteInput
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePickerStateChange = useCallback((state: AutocompletePickerState<any>) => {
setPickerState(state)
}, [])
const handleFileSelect = useCallback(
(_result: FileSearchResult) => {
// File selection is handled by FilePickerInput.
// It will update the text input directly.
clearFilePicker()
},
[clearFilePicker],
)
// Handle item selection from external PickerSelect
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlePickerSelect = useCallback((item: any) => {
autocompleteRef.current?.handleItemSelect(item)
followupAutocompleteRef.current?.handleItemSelect(item)
}, [])
const handleFilePickerClose = useCallback(() => {
clearFilePicker()
}, [clearFilePicker])
// Handle picker close from external PickerSelect
const handlePickerClose = useCallback(() => {
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
}, [])
// Handle picker index change from external PickerSelect
const handlePickerIndexChange = useCallback((index: number) => {
autocompleteRef.current?.handleIndexChange(index)
followupAutocompleteRef.current?.handleIndexChange(index)
}, [])
// Error display
if (error) {
@ -687,165 +783,172 @@ export function App({
)
}
// Status bar message - shows exit hint or default text
// Status bar content
const statusBarMessage = showExitHint ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : (
<Text color={theme.dimText}> history ? for shortcuts</Text>
)
) : isLoading ? (
<Box>
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
{isScrollAreaActive && (
<>
<Text color={theme.dimText}> </Text>
<ScrollIndicator
scrollTop={scrollState.scrollTop}
maxScroll={scrollState.maxScroll}
isScrollFocused={true}
/>
</>
)}
</Box>
) : isScrollAreaActive ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : null
// Get render function for picker items based on active trigger
const getPickerRenderItem = () => {
if (pickerState.activeTrigger) {
return pickerState.activeTrigger.renderItem
}
// Default render
return (item: FileResult | SlashCommandItem, isSelected: boolean) => (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
</Box>
)
}
return (
<Box flexDirection="column">
{/* Header with ASCII art */}
<Header model={model} mode={mode} cwd={workspacePath} reasoningEffort={reasoningEffort} />
<Box flexDirection="column" height={rows - 1}>
{/* Header - fixed size */}
<Box flexShrink={0}>
<Header
model={model}
mode={mode}
cwd={workspacePath}
reasoningEffort={reasoningEffort}
version={version}
/>
</Box>
{/* Message history - render all completed messages */}
{displayMessages.map((message) => (
<ChatHistoryItem key={message.id} message={message} />
))}
{/* Scrollable message history area - fills remaining space via flexGrow */}
<ScrollArea
isActive={isScrollAreaActive}
onScroll={handleScroll}
scrollToBottomTrigger={scrollToBottomTrigger}>
{displayMessages.map((message) => (
<ChatHistoryItem key={message.id} message={message} />
))}
</ScrollArea>
{/* Input area - with borders like Claude Code */}
<Box flexDirection="column" marginTop={1}>
{view === "UserInput" ? (
pendingAsk?.type === "followup" ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine />
<Select
options={[
...pendingAsk.suggestions.map((s) => ({
label: s.answer,
value: s.answer,
})),
{ label: "Type something...", value: "__CUSTOM__" },
]}
onChange={(value) => {
// Guard: Ignore empty, undefined, or invalid values
if (!value || typeof value !== "string") return
{/* Input area - with borders like Claude Code - fixed size */}
<Box flexDirection="column" flexShrink={0}>
{pendingAsk?.type === "followup" ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={true} />
<Select
options={[
...pendingAsk.suggestions.map((s) => ({
label: s.answer,
value: s.answer,
})),
{ label: "Type something...", value: "__CUSTOM__" },
]}
onChange={(value) => {
if (!value || typeof value !== "string") return
if (showCustomInput || isTransitioningToCustomInput.current) return
// Guard: Ignore if we're already transitioning or showing custom input
if (showCustomInput || isTransitioningToCustomInput.current) return
if (value === "__CUSTOM__") {
// Don't send any response - just switch to text input mode
// Use ref to prevent race conditions during state update
isTransitioningToCustomInput.current = true
setShowCustomInput(true)
} else if (value.trim()) {
// Only submit valid non-empty values
handleSubmit(value)
}
}}
/>
<HorizontalLine />
<Text color={theme.dimText}> navigate Enter select</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine />
<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>
)}
</Box>
) : pendingAsk ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
<Text color={theme.dimText}>
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
<Text color={theme.errorColor}>N</Text> to reject
</Text>
</Box>
) : isComplete ? (
<Box flexDirection="column">
<HorizontalLine />
<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 && (
<FilePickerSelect
results={fileSearchResults}
selectedIndex={filePickerSelectedIndex}
maxVisible={10}
onSelect={handleFileSelect}
onEscape={handleFilePickerClose}
onIndexChange={setFilePickerSelectedIndex}
isActive={view === "UserInput" && isFilePickerOpen}
if (value === "__CUSTOM__") {
isTransitioningToCustomInput.current = true
setShowCustomInput(true)
} else if (value.trim()) {
handleSubmit(value)
}
}}
/>
)}
</Box>
) : (
<Box flexDirection="column">
<HorizontalLine />
<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 && (
<FilePickerSelect
results={fileSearchResults}
selectedIndex={filePickerSelectedIndex}
maxVisible={10}
onSelect={handleFileSelect}
onEscape={handleFilePickerClose}
onIndexChange={setFilePickerSelectedIndex}
isActive={view === "UserInput" && isFilePickerOpen}
<HorizontalLine active={true} />
<Text color={theme.dimText}> navigate Enter select</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={followupAutocompleteRef}
placeholder="Type your response..."
onSubmit={(text: string) => {
if (text && text.trim()) {
handleSubmit(text)
setShowCustomInput(false)
isTransitioningToCustomInput.current = false
}
}}
isActive={true}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt="> "
/>
)}
</Box>
)
) : view === "ToolUse" ? (
<Box paddingX={1}>
<LoadingText>Using tool</LoadingText>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
) : showApprovalPrompt ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk?.content}</Text>
<Text color={theme.dimText}>
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
<Text color={theme.errorColor}>N</Text> to reject
</Text>
<Box height={1}>{statusBarMessage}</Box>
</Box>
) : (
<Box paddingX={1}>
<LoadingText>Thinking</LoadingText>
<Box flexDirection="column">
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={autocompleteRef}
placeholder={isComplete ? "Type to continue..." : ""}
onSubmit={handleSubmit}
isActive={isInputAreaActive}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt=" "
/>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
@ -853,13 +956,23 @@ export function App({
)
}
/**
* Main TUI Application Component - wraps with TerminalSizeProvider
*/
export function App(props: TUIAppProps) {
return (
<TerminalSizeProvider>
<AppInner {...props} />
</TerminalSizeProvider>
)
}
/**
* Format tool output for display (used in the message body, header shows tool name separately)
*/
function formatToolOutput(toolInfo: Record<string, unknown>): string {
const toolName = (toolInfo.tool as string) || "unknown"
// Handle specific tool types with friendly formatting
switch (toolName) {
case "switchMode": {
const mode = (toolInfo.mode as string) || "unknown"
@ -935,7 +1048,6 @@ function formatToolOutput(toolInfo: Record<string, unknown>): string {
}
default: {
// Generic formatting - show params without the tool name (it's in the header)
const params = Object.entries(toolInfo)
.filter(([key]) => key !== "tool")
.map(([key, value]) => {
@ -955,7 +1067,6 @@ function formatToolOutput(toolInfo: Record<string, unknown>): string {
function formatToolAskMessage(toolInfo: Record<string, unknown>): string {
const toolName = (toolInfo.tool as string) || "unknown"
// Handle specific tool types with nice formatting for approval prompts
switch (toolName) {
case "switchMode":
case "switch_mode": {
@ -995,7 +1106,6 @@ function formatToolAskMessage(toolInfo: Record<string, unknown>): string {
}
default: {
// Generic formatting for other tools
const params = Object.entries(toolInfo)
.filter(([key]) => key !== "tool")
.map(([key, value]) => {

View file

@ -48,18 +48,28 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
</Text>
</Box>
)
case "tool":
case "tool": {
let toolContent = message.toolDisplayOutput || content
// Replace tab characters with spaces to prevent terminal width miscalculation
// Tabs expand to variable widths in terminals, causing layout issues
toolContent = toolContent.replace(/\t/g, " ")
// Also strip any carriage returns that could cause issues
toolContent = toolContent.replace(/\r/g, "")
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.toolHeader}>
{`tool - ${message.toolDisplayName || message.toolName || "unknown"}`}
</Text>
<Text color={theme.toolText}>
{message.toolDisplayOutput || content}
{toolContent}
<Newline />
</Text>
</Box>
)
}
case "system":
// System messages are typically rendered as Header, not here.
// But if they appear, show them subtly.

View file

@ -1,229 +0,0 @@
import { useInput } from "ink"
import { useState, useCallback, useEffect, useRef } from "react"
import { MultilineTextInput } from "./MultilineTextInput.js"
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
/**
* Prompt character for the first line (default: "> ")
*/
prompt?: string
/**
* Indent string for continuation lines (default: " ")
*/
continuationIndent?: string
}
const SEARCH_DEBOUNCE_MS = 150
export function FilePickerInput({
placeholder = "Type your message...",
onSubmit,
isActive = true,
onFileSearch,
fileSearchResults,
isFilePickerOpen,
filePickerSelectedIndex,
onFilePickerClose,
prompt = "> ",
continuationIndent = " ",
}: FilePickerInputProps) {
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
const lastSearchQueryRef = useRef<string | null>(null)
const [inputValue, setInputValue] = useState("")
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) {
setInputValue(historyValue)
}
} else if (!isBrowsing && wasBrowsing) {
setInputValue(draft)
} else if (isBrowsing && historyValue !== null && historyValue !== inputValue) {
setInputValue(historyValue)
}
setWasBrowsing(isBrowsing)
}, [isBrowsing, wasBrowsing, historyValue, draft, inputValue])
// Cleanup debounce timer
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [])
const checkForAtTrigger = useCallback(
(value: string) => {
// 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) {
onFilePickerClose()
}
return
}
const query = lastLine.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) => {
setInputValue(value)
checkForAtTrigger(value)
if (!isBrowsing) {
setDraft(value)
}
},
[checkForAtTrigger, isBrowsing, setDraft],
)
const handleFileSelect = useCallback(
(result: FileSearchResult) => {
// 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 = lastLine.substring(0, atIndex)
lines[lastLineIndex] = `${beforeAt}@/${result.path} `
const newValue = lines.join("\n")
setInputValue(newValue)
setDraft(newValue)
lastSearchQueryRef.current = null
onFilePickerClose()
}
},
[inputValue, onFilePickerClose, setDraft],
)
const handleSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
if (isFilePickerOpen) {
return
}
await addEntry(trimmed)
resetBrowsing("")
lastSearchQueryRef.current = null
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) {
return
}
if (key.return) {
const selected = fileSearchResults[filePickerSelectedIndex]
if (selected) {
handleFileSelect(selected)
}
}
},
{ isActive: isActive && isFilePickerOpen },
)
return (
<MultilineTextInput
key={`file-picker-input-${history.length}`}
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
onEscape={handleEscape}
onUpAtFirstLine={navigateUp}
onDownAtLastLine={navigateDown}
placeholder={placeholder}
isActive={isActive}
showCursor={true}
prompt={prompt}
continuationIndent={continuationIndent}
/>
)
}

View file

@ -1,108 +0,0 @@
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,7 +1,7 @@
import { memo } from "react"
import { Text, Box } from "ink"
import { useTerminalSize } from "../hooks/useTerminalSize.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../utils/theme.js"
interface HeaderProps {
@ -9,7 +9,7 @@ interface HeaderProps {
model: string
mode: string
reasoningEffort?: string
version?: string
version: string
}
const ASCII_ROO = ` _,' ___
@ -19,13 +19,9 @@ const ASCII_ROO = ` _,' ___
// \\\\
,/' \`\\_,`
function HorizontalLine() {
function Header({ model, cwd, mode, reasoningEffort, version }: HeaderProps) {
const { columns } = useTerminalSize()
return <Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
}
function Header({ model, cwd, mode, reasoningEffort, version = "0.1.0" }: HeaderProps) {
const { columns } = useTerminalSize()
const homeDir = process.env.HOME || process.env.USERPROFILE || ""
const displayCwd = cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd
const title = `Roo Code CLI v${version}`
@ -50,7 +46,8 @@ function Header({ model, cwd, mode, reasoningEffort, version = "0.1.0" }: Header
</Box>
</Box>
</Box>
<HorizontalLine />
{/* Inline horizontal line using the same columns value */}
<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
</Box>
)
}

View file

@ -12,7 +12,7 @@
* - Arrow keys: Navigate within and between lines
*/
import { useState, useEffect, useMemo, useCallback } from "react"
import { useState, useEffect, useMemo, useCallback, useRef } from "react"
import { Box, Text, useInput, type Key } from "ink"
export interface MultilineTextInputProps {
@ -62,6 +62,11 @@ export interface MultilineTextInputProps {
* Indent string for continuation lines
*/
continuationIndent?: string
/**
* Terminal width in columns - used for proper line wrapping
* If not provided, lines won't be wrapped
*/
columns?: number
}
/**
@ -104,6 +109,57 @@ function getIndexFromPosition(value: string, line: number, col: number): number
return index
}
/**
* Represents a visual row after wrapping a logical line
*/
interface VisualRow {
text: string
logicalLineIndex: number
isFirstRowOfLine: boolean
startCol: number // column offset in the logical line
}
/**
* Wrap a logical line into visual rows based on available width
*/
function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
if (availableWidth <= 0 || lineText.length <= availableWidth) {
return [
{
text: lineText,
logicalLineIndex,
isFirstRowOfLine: true,
startCol: 0,
},
]
}
const rows: VisualRow[] = []
let remaining = lineText
let startCol = 0
let isFirst = true
while (remaining.length > 0) {
const chunk = remaining.slice(0, availableWidth)
rows.push({
text: chunk,
logicalLineIndex,
isFirstRowOfLine: isFirst,
startCol,
})
remaining = remaining.slice(availableWidth)
startCol += availableWidth
isFirst = false
}
// If the line ends exactly at the width boundary, add an empty row for cursor
if (lineText.length > 0 && lineText.length % availableWidth === 0) {
// The last row already exists, no need to add empty row
}
return rows
}
export function MultilineTextInput({
value,
onChange,
@ -116,9 +172,20 @@ export function MultilineTextInput({
showCursor = true,
prompt = "> ",
continuationIndent = " ",
columns,
}: MultilineTextInputProps) {
const [cursorIndex, setCursorIndex] = useState(value.length)
// Use refs to track the latest values for use in the useInput callback.
// This prevents stale closure issues when multiple keystrokes arrive
// faster than React can re-render.
const valueRef = useRef(value)
const cursorIndexRef = useRef(cursorIndex)
// Keep refs in sync with state/props - these updates are synchronous
valueRef.current = value
cursorIndexRef.current = cursorIndex
// Clamp cursor if value changes externally
useEffect(() => {
if (cursorIndex > value.length) {
@ -129,6 +196,10 @@ export function MultilineTextInput({
// Handle keyboard input
useInput(
(input: string, key: Key) => {
// Read from refs to get the latest values, not stale closure captures
const currentValue = valueRef.current
const currentCursorIndex = cursorIndexRef.current
// Escape: clear all
if (key.escape) {
onEscape?.()
@ -142,15 +213,20 @@ export function MultilineTextInput({
// Ctrl+Enter: add new line
if (key.return && key.ctrl) {
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
const newValue =
currentValue.slice(0, currentCursorIndex) + "\n" + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(cursorIndex + 1)
setCursorIndex(newCursorIndex)
return
}
// Enter (without Ctrl): submit
if (key.return) {
onSubmit?.(value)
onSubmit?.(currentValue)
return
}
@ -162,14 +238,16 @@ export function MultilineTextInput({
// 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)
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
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))
const newCursorIndex = getIndexFromPosition(currentValue, line - 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On first line - trigger history navigation callback
onUpAtFirstLine?.()
@ -180,14 +258,16 @@ export function MultilineTextInput({
// 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)
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
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))
const newCursorIndex = getIndexFromPosition(currentValue, line + 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On last line - trigger history navigation callback
onDownAtLastLine?.()
@ -198,23 +278,32 @@ export function MultilineTextInput({
// Arrow left: move cursor left
if (key.leftArrow) {
if (!showCursor) return
setCursorIndex(Math.max(0, cursorIndex - 1))
const newCursorIndex = Math.max(0, currentCursorIndex - 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Arrow right: move cursor right
if (key.rightArrow) {
if (!showCursor) return
setCursorIndex(Math.min(value.length, cursorIndex + 1))
const newCursorIndex = Math.min(currentValue.length, currentCursorIndex + 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Backspace/Delete
if (key.backspace || key.delete) {
if (cursorIndex > 0) {
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
if (currentCursorIndex > 0) {
const newValue =
currentValue.slice(0, currentCursorIndex - 1) + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex - 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(cursorIndex - 1)
setCursorIndex(newCursorIndex)
}
return
}
@ -222,9 +311,14 @@ export function MultilineTextInput({
// Normal character input
if (input) {
const normalized = normalizeLineEndings(input)
const newValue = value.slice(0, cursorIndex) + normalized + value.slice(cursorIndex)
const newValue =
currentValue.slice(0, currentCursorIndex) + normalized + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + normalized.length
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(cursorIndex + normalized.length)
setCursorIndex(newCursorIndex)
}
},
{ isActive },
@ -247,23 +341,66 @@ export function MultilineTextInput({
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
// Calculate visual rows with wrapping
const visualRows = useMemo(() => {
const rows: VisualRow[] = []
const promptLen = prompt.length
const indentLen = continuationIndent.length
// 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)
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]!
const prefixLen = i === 0 ? promptLen : indentLen
// Calculate available width for text (terminal width minus prefix)
// Use a large number if columns is not provided
const availableWidth = columns ? Math.max(1, columns - prefixLen) : 10000
const lineRows = wrapLine(lineText, i, availableWidth)
rows.push(...lineRows)
}
return rows
}, [lines, columns, prompt.length, continuationIndent.length])
// Render a visual row with optional cursor
const renderVisualRow = useCallback(
(row: VisualRow, rowIndex: number) => {
const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0
const isFirstLine = row.logicalLineIndex === 0
// Only show prefix on the first visual row of each logical line
const linePrefix = row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : ""
// Pad continuation rows to align with the text
const padding = !row.isFirstRowOfLine ? (isFirstLine ? prompt : continuationIndent) : ""
// Check if cursor is on this visual row
let hasCursor = false
let cursorColInRow = -1
if (cursorPosition && cursorPosition.line === row.logicalLineIndex && isActive) {
const cursorCol = cursorPosition.col
// Check if cursor falls within this visual row's range
if (cursorCol >= row.startCol && cursorCol < row.startCol + row.text.length) {
hasCursor = true
cursorColInRow = cursorCol - row.startCol
}
// Cursor at the end of this row (for the last row of a line)
else if (cursorCol === row.startCol + row.text.length) {
// Check if this is the last visual row for this logical line
const nextRow = visualRows[rowIndex + 1]
if (!nextRow || nextRow.logicalLineIndex !== row.logicalLineIndex) {
hasCursor = true
cursorColInRow = row.text.length
}
}
}
if (hasCursor) {
const beforeCursor = row.text.slice(0, cursorColInRow)
const cursorChar = row.text[cursorColInRow] || " "
const afterCursor = row.text.slice(cursorColInRow + 1)
return (
<Box key={lineIndex}>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Box key={rowIndex}>
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
<Text>{beforeCursor}</Text>
<Text inverse>{cursorChar}</Text>
<Text>{afterCursor}</Text>
@ -272,14 +409,14 @@ export function MultilineTextInput({
}
return (
<Box key={lineIndex}>
<Text dimColor={!isFirstLine}>{linePrefix}</Text>
<Text dimColor={isPlaceholder}>{lineText}</Text>
<Box key={rowIndex}>
<Text dimColor={!isFirstLine || !row.isFirstRowOfLine}>{linePrefix || padding}</Text>
<Text dimColor={isPlaceholder}>{row.text}</Text>
</Box>
)
},
[prompt, continuationIndent, cursorPosition, value, isActive],
[prompt, continuationIndent, cursorPosition, value, isActive, visualRows],
)
return <Box flexDirection="column">{lines.map((line, index) => renderLine(line, index))}</Box>
return <Box flexDirection="column">{visualRows.map((row, index) => renderVisualRow(row, index))}</Box>
}

View file

@ -0,0 +1,382 @@
import { Box, DOMElement, measureElement, Text, useInput } from "ink"
import { useEffect, useReducer, useRef, useCallback, useMemo, useState } from "react"
import * as theme from "../utils/theme.js"
interface ScrollAreaState {
innerHeight: number
height: number
scrollTop: number
autoScroll: boolean
}
function calculateScrollbar(
viewportHeight: number,
contentHeight: number,
scrollTop: number,
): { handleStart: number; handleHeight: number; maxScroll: number } {
const maxScroll = Math.max(0, contentHeight - viewportHeight)
if (contentHeight <= viewportHeight || maxScroll === 0) {
// No scrolling needed - handle fills entire track
return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 }
}
// Calculate handle height as ratio of viewport to content (minimum 1 line)
const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight))
// Calculate handle position
const trackSpace = viewportHeight - handleHeight
const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0
const handleStart = Math.round(scrollRatio * trackSpace)
return { handleStart, handleHeight, maxScroll }
}
type ScrollAreaAction =
| { type: "SET_INNER_HEIGHT"; innerHeight: number }
| { type: "SET_HEIGHT"; height: number }
| { type: "SCROLL_DOWN"; amount?: number }
| { type: "SCROLL_UP"; amount?: number }
| { type: "SCROLL_TO_BOTTOM" }
| { type: "SCROLL_TO_LINE"; line: number }
| { type: "SET_AUTO_SCROLL"; autoScroll: boolean }
function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState {
const maxScroll = Math.max(0, state.innerHeight - state.height)
switch (action.type) {
case "SET_INNER_HEIGHT": {
const newMaxScroll = Math.max(0, action.innerHeight - state.height)
// If auto-scroll is enabled and content grew, scroll to bottom
if (state.autoScroll && action.innerHeight > state.innerHeight) {
return {
...state,
innerHeight: action.innerHeight,
scrollTop: newMaxScroll,
}
}
// Clamp scrollTop to valid range
return {
...state,
innerHeight: action.innerHeight,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SET_HEIGHT": {
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
// If auto-scroll is enabled, stay at bottom
if (state.autoScroll) {
return {
...state,
height: action.height,
scrollTop: newMaxScroll,
}
}
// Clamp scrollTop to valid range
return {
...state,
height: action.height,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SCROLL_DOWN": {
const amount = action.amount || 1
const newScrollTop = Math.min(maxScroll, state.scrollTop + amount)
// If we scroll to the bottom, re-enable auto-scroll
const atBottom = newScrollTop >= maxScroll
return {
...state,
scrollTop: newScrollTop,
autoScroll: atBottom,
}
}
case "SCROLL_UP": {
const amount = action.amount || 1
const newScrollTop = Math.max(0, state.scrollTop - amount)
// Disable auto-scroll when user scrolls up
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
case "SCROLL_TO_BOTTOM":
return {
...state,
scrollTop: maxScroll,
autoScroll: true,
}
case "SCROLL_TO_LINE": {
// Scroll to make a specific line visible
// If line is above viewport, scroll up to show it at the top
// If line is below viewport, scroll down to show it at the bottom
const line = action.line
const viewportBottom = state.scrollTop + state.height - 1
if (line < state.scrollTop) {
// Line is above viewport - scroll up to show it at the top
return {
...state,
scrollTop: Math.max(0, line),
autoScroll: false,
}
} else if (line > viewportBottom) {
// Line is below viewport - scroll down to show it at the bottom
const newScrollTop = Math.min(maxScroll, line - state.height + 1)
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
// Line is already visible - no change needed
return state
}
case "SET_AUTO_SCROLL":
return {
...state,
autoScroll: action.autoScroll,
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
}
default:
return state
}
}
export interface ScrollAreaProps {
height?: number
children: React.ReactNode
isActive?: boolean
onScroll?: (scrollTop: number, maxScroll: number, isAtBottom: boolean) => void
showBorder?: boolean
scrollToBottomTrigger?: number
scrollToLine?: number
scrollToLineTrigger?: number
showScrollbar?: boolean
}
export function ScrollArea({
height: heightProp,
children,
isActive = true,
onScroll,
showBorder = false,
scrollToBottomTrigger,
scrollToLine,
scrollToLineTrigger,
showScrollbar = true,
}: ScrollAreaProps) {
// Ref for measuring outer container height when not provided
const outerRef = useRef<DOMElement>(null)
const [measuredHeight, setMeasuredHeight] = useState(0)
// Use provided height or measured height
const height = heightProp ?? measuredHeight
const [state, dispatch] = useReducer(reducer, {
height: height,
scrollTop: 0,
innerHeight: 0,
autoScroll: true,
})
const innerRef = useRef<DOMElement>(null)
const lastMeasuredHeight = useRef<number>(0)
// Update height when prop changes
useEffect(() => {
if (height > 0) {
dispatch({ type: "SET_HEIGHT", height })
}
}, [height])
// Measure outer container height when no height prop is provided
useEffect(() => {
if (heightProp !== undefined) return // Skip if height is provided
const measureOuter = () => {
if (!outerRef.current) return
const dimensions = measureElement(outerRef.current)
if (dimensions.height !== measuredHeight && dimensions.height > 0) {
setMeasuredHeight(dimensions.height)
}
}
// Initial measurement
measureOuter()
// Re-measure periodically to catch layout changes
const interval = setInterval(measureOuter, 100)
return () => {
clearInterval(interval)
}
}, [heightProp, measuredHeight])
// Scroll to bottom when trigger changes
useEffect(() => {
if (scrollToBottomTrigger !== undefined && scrollToBottomTrigger > 0) {
dispatch({ type: "SCROLL_TO_BOTTOM" })
}
}, [scrollToBottomTrigger])
// Scroll to specific line when trigger changes
useEffect(() => {
if (scrollToLineTrigger !== undefined && scrollToLineTrigger > 0 && scrollToLine !== undefined) {
dispatch({ type: "SCROLL_TO_LINE", line: scrollToLine })
}
}, [scrollToLineTrigger, scrollToLine])
// Measure inner content height - use MutationObserver pattern for dynamic content
useEffect(() => {
if (!innerRef.current) return
const measureAndUpdate = () => {
if (!innerRef.current) return
const dimensions = measureElement(innerRef.current)
if (dimensions.height !== lastMeasuredHeight.current) {
lastMeasuredHeight.current = dimensions.height
dispatch({ type: "SET_INNER_HEIGHT", innerHeight: dimensions.height })
}
}
// Initial measurement
measureAndUpdate()
// Re-measure periodically while component is mounted
// This handles streaming content that changes size
const interval = setInterval(measureAndUpdate, 100)
return () => {
clearInterval(interval)
}
}, [children])
// Notify parent of scroll changes
useEffect(() => {
if (onScroll) {
const maxScroll = Math.max(0, state.innerHeight - state.height)
const isAtBottom = state.scrollTop >= maxScroll || maxScroll === 0
onScroll(state.scrollTop, maxScroll, isAtBottom)
}
}, [state.scrollTop, state.innerHeight, state.height, onScroll])
// Handle keyboard input for scrolling
useInput(
(_input, key) => {
if (!isActive) return
if (key.downArrow) {
dispatch({ type: "SCROLL_DOWN" })
}
if (key.upArrow) {
dispatch({ type: "SCROLL_UP" })
}
if (key.pageDown) {
dispatch({ type: "SCROLL_DOWN", amount: Math.floor(state.height / 2) })
}
if (key.pageUp) {
dispatch({ type: "SCROLL_UP", amount: Math.floor(state.height / 2) })
}
// Home - scroll to top
if (key.ctrl && _input === "a") {
dispatch({ type: "SCROLL_UP", amount: state.scrollTop })
}
// End - scroll to bottom
if (key.ctrl && _input === "e") {
dispatch({ type: "SCROLL_TO_BOTTOM" })
}
},
{ isActive },
)
// Calculate scrollbar dimensions
const scrollbar = useMemo(() => {
return calculateScrollbar(state.height, state.innerHeight, state.scrollTop)
}, [state.height, state.innerHeight, state.scrollTop])
// Determine if scrollbar should be visible
// Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state)
// Hide scrollbar only when: not focused AND nothing to scroll
const showScrollbarVisible = showScrollbar && (scrollbar.maxScroll > 0 || isActive)
// Scrollbar colors based on focus state
// When active: handle is bright purple, track is dim purple
// When inactive: handle is dim gray, track is very dim
const handleColor = isActive ? theme.scrollActiveColor : theme.dimText
// When no height prop is provided, use flexGrow to fill available space
const useFlexGrow = heightProp === undefined
return (
<Box
ref={outerRef}
flexDirection="row"
height={useFlexGrow ? undefined : height}
flexGrow={useFlexGrow ? 1 : undefined}
flexShrink={useFlexGrow ? 1 : undefined}
overflow="hidden">
{/* Scroll content area */}
<Box
height={useFlexGrow ? undefined : height}
borderStyle={showBorder ? "single" : undefined}
flexDirection="column"
flexGrow={1}
flexShrink={1}
overflow="hidden">
<Box ref={innerRef} flexShrink={0} flexDirection="column" marginTop={-state.scrollTop}>
{children}
</Box>
</Box>
{/* Scrollbar - rendered as a single Text element to avoid per-character wrapping issues */}
{showScrollbar && (
<Box flexDirection="column" width={1} flexShrink={0} overflow="hidden">
{showScrollbarVisible && height > 0 && (
<Text wrap="truncate" color={handleColor}>
{Array(height)
.fill(null)
.map((_, i) => {
const isHandle =
i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
return isHandle ? "█" : "░"
})
.join("\n")}
</Text>
)}
</Box>
)}
</Box>
)
}
/**
* Hook to use with ScrollArea for external control
*/
export function useScrollToBottom() {
const triggerRef = useRef(0)
const [, forceUpdate] = useReducer((x) => x + 1, 0)
const scrollToBottom = useCallback(() => {
triggerRef.current += 1
forceUpdate()
}, [])
return {
scrollToBottomTrigger: triggerRef.current,
scrollToBottom,
}
}

View file

@ -0,0 +1,26 @@
import { Box, Text } from "ink"
import { memo } from "react"
import * as theme from "../utils/theme.js"
interface ScrollIndicatorProps {
scrollTop: number
maxScroll: number
isScrollFocused?: boolean
}
function ScrollIndicator({ scrollTop, maxScroll, isScrollFocused = false }: ScrollIndicatorProps) {
// Calculate percentage - show 100% when at bottom or no scrolling needed
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 100
// Color changes based on focus state
const color = isScrollFocused ? theme.scrollActiveColor : theme.dimText
return (
<Box>
<Text color={color}>{percentage}% scroll Ctrl+E end</Text>
</Box>
)
}
export default memo(ScrollIndicator)

View file

@ -1,162 +0,0 @@
/**
* TextInput Component - User input field for the TUI
* Uses @inkjs/ui TextInput for ink v6 compatibility
*/
import { Box, Text, useInput } from "ink"
import { TextInput as InkTextInput } from "@inkjs/ui"
import { useState, useCallback } from "react"
export interface TextInputProps {
/** Current input value */
value: string
/** Called when input changes */
onChange: (value: string) => void
/** Called when user submits input */
onSubmit: (value: string) => void
/** Placeholder text when empty */
placeholder?: string
/** Whether input is disabled */
disabled?: boolean
}
/**
* Text input component with submit handling
*/
export function TextInput({
value,
onChange,
onSubmit,
placeholder = "Type your message...",
disabled = false,
}: TextInputProps) {
const handleSubmit = useCallback(
(inputValue: string) => {
const trimmed = inputValue.trim()
if (trimmed && !disabled) {
onSubmit(trimmed)
onChange("") // Clear input after submit
}
},
[onSubmit, onChange, disabled],
)
if (disabled) {
return (
<Box borderStyle="bold" borderColor="gray" paddingX={1}>
<Text color="gray" dimColor>
{placeholder}
</Text>
</Box>
)
}
return (
<Box borderStyle="bold" borderColor="blue" paddingX={1}>
<InkTextInput defaultValue={value} placeholder={placeholder} onSubmit={handleSubmit} />
</Box>
)
}
export interface ApprovalPromptProps {
/** The question or action to approve */
message: string
/** Suggested answers (for followup questions) */
suggestions?: Array<{ answer: string; mode?: string | null }>
/** Called when user approves */
onApprove: () => void
/** Called when user rejects */
onReject: () => void
/** Called when user provides text response */
onTextResponse?: (text: string) => void
}
/**
* Approval prompt for yes/no decisions
*/
export function ApprovalPrompt({ message, suggestions, onApprove, onReject, onTextResponse }: ApprovalPromptProps) {
const [inputValue, _setInputValue] = useState("")
// Handle keyboard input for Y/N
useInput((input) => {
const lower = input.toLowerCase()
if (lower === "y") {
onApprove()
} else if (lower === "n") {
onReject()
} else if (suggestions && !isNaN(parseInt(input, 10))) {
const index = parseInt(input, 10) - 1
const suggestion = suggestions[index]
if (index >= 0 && index < suggestions.length && suggestion) {
onTextResponse?.(suggestion.answer)
}
}
})
return (
<Box flexDirection="column" borderStyle="bold" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
{message}
</Text>
{suggestions && suggestions.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Suggestions:</Text>
{suggestions.map((suggestion, index) => (
<Box key={index}>
<Text color="cyan">
{index + 1}. {suggestion.answer}
{suggestion.mode && (
<Text color="gray" dimColor>
{" "}
(mode: {suggestion.mode})
</Text>
)}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
{onTextResponse ? (
<Box flexDirection="column">
<Text color="gray">
Type a number (1-{suggestions?.length || 0}), type your answer, or press{" "}
<Text color="green" bold>
Y
</Text>
/
<Text color="red" bold>
N
</Text>
</Text>
<Box marginTop={1} borderStyle="bold" borderColor="blue" paddingX={1}>
<InkTextInput
defaultValue={inputValue}
placeholder="Your response..."
onSubmit={(val) => {
if (val.trim()) {
onTextResponse(val.trim())
}
}}
/>
</Box>
</Box>
) : (
<Text color="gray">
Press{" "}
<Text color="green" bold>
Y
</Text>{" "}
to approve,{" "}
<Text color="red" bold>
N
</Text>{" "}
to reject
</Text>
)}
</Box>
</Box>
)
}

View file

@ -0,0 +1,281 @@
import { useInput } from "ink"
import { useState, useCallback, useEffect, useImperativeHandle, forwardRef, type Ref } from "react"
import { MultilineTextInput } from "../MultilineTextInput.js"
import { useInputHistory } from "../../hooks/useInputHistory.js"
import { useAutocompletePicker } from "./useAutocompletePicker.js"
import { useTerminalSize } from "../../hooks/TerminalSizeContext.js"
import type { AutocompleteItem, AutocompleteTrigger, AutocompletePickerState } from "./types.js"
export interface AutocompleteInputProps<T extends AutocompleteItem = AutocompleteItem> {
/** Placeholder text when input is empty */
placeholder?: string
/** Called when user submits text (Enter without picker open) */
onSubmit: (value: string) => void
/** Whether the input is active/focused */
isActive?: boolean
/** Array of autocomplete triggers to enable */
triggers: AutocompleteTrigger<T>[]
/** Called when an item is selected from the picker */
onSelect?: (item: T) => void
/** Called when picker state changes - use this to render PickerSelect externally */
onPickerStateChange?: (state: AutocompletePickerState<T>) => void
/** Prompt character for the first line (default: "> ") */
prompt?: string
/** Indent string for continuation lines (default: " ") */
continuationIndent?: string
}
/**
* Ref handle for AutocompleteInput - allows parent to access picker state and actions
*/
export interface AutocompleteInputHandle<T extends AutocompleteItem = AutocompleteItem> {
/** Current picker state */
pickerState: AutocompletePickerState<T>
/** Handle item selection from external picker */
handleItemSelect: (item: T) => void
/** Handle index change from external picker */
handleIndexChange: (index: number) => void
/** Close the picker */
closePicker: () => void
}
/**
* Inner component implementation
*/
function AutocompleteInputInner<T extends AutocompleteItem>(
{
placeholder = "Type your message...",
onSubmit,
isActive = true,
triggers,
onSelect,
onPickerStateChange,
prompt = "> ",
continuationIndent = " ",
}: AutocompleteInputProps<T>,
ref: Ref<AutocompleteInputHandle<T>>,
) {
const [inputValue, setInputValue] = useState("")
// Counter to force re-mount of MultilineTextInput to move cursor to end
const [inputKeyCounter, setInputKeyCounter] = useState(0)
// Get terminal size for proper line wrapping
const { columns } = useTerminalSize()
// Autocomplete picker state
const [pickerState, pickerActions] = useAutocompletePicker(triggers)
// Input history
const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft, navigateUp, navigateDown } =
useInputHistory({
isActive: isActive && !pickerState.isOpen,
getCurrentInput: () => inputValue,
})
const [wasBrowsing, setWasBrowsing] = useState(false)
// Notify parent of picker state changes
useEffect(() => {
onPickerStateChange?.(pickerState)
}, [pickerState, onPickerStateChange])
// Handle history navigation
useEffect(() => {
if (isBrowsing && !wasBrowsing) {
if (historyValue !== null) {
setInputValue(historyValue)
}
} else if (!isBrowsing && wasBrowsing) {
setInputValue(draft)
} else if (isBrowsing && historyValue !== null && historyValue !== inputValue) {
setInputValue(historyValue)
}
setWasBrowsing(isBrowsing)
}, [isBrowsing, wasBrowsing, historyValue, draft, inputValue])
/**
* Get the last line from input value
*/
const getLastLine = useCallback((value: string): string => {
const lines = value.split("\n")
return lines[lines.length - 1] || ""
}, [])
/**
* Handle input value changes
*/
const handleChange = useCallback(
(value: string) => {
setInputValue(value)
// Check for trigger activation
const lastLine = getLastLine(value)
pickerActions.handleInputChange(value, lastLine)
if (!isBrowsing) {
setDraft(value)
}
},
[pickerActions, isBrowsing, setDraft, getLastLine],
)
/**
* Handle item selection from picker
*/
const handleItemSelect = useCallback(
(item: T) => {
const lastLine = getLastLine(inputValue)
const newValue = pickerActions.handleSelect(item, inputValue, lastLine)
setInputValue(newValue)
setDraft(newValue)
// Increment counter to force re-mount and move cursor to end
setInputKeyCounter((c) => c + 1)
// Notify parent
onSelect?.(item)
},
[inputValue, pickerActions, setDraft, getLastLine, onSelect],
)
/**
* Handle form submission
*/
const handleSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
// Don't submit if picker is open
if (pickerState.isOpen) {
return
}
await addEntry(trimmed)
resetBrowsing("")
setInputValue("")
onSubmit(trimmed)
},
[pickerState.isOpen, addEntry, resetBrowsing, onSubmit],
)
/**
* Handle escape key
*/
const handleEscape = useCallback(() => {
// If picker is open, close it without clearing text
if (pickerState.isOpen) {
pickerActions.handleClose()
return
}
// Clear all input on Escape when picker is not open
setInputValue("")
setDraft("")
resetBrowsing("")
}, [pickerState.isOpen, pickerActions, setDraft, resetBrowsing])
// Handle picker selection with Enter or Tab
useInput(
(_input, key) => {
if (!isActive || !pickerState.isOpen) {
return
}
// Select current item on Enter or Tab
if (key.return || key.tab) {
const selected = pickerState.results[pickerState.selectedIndex]
if (selected) {
handleItemSelect(selected)
}
}
},
{ isActive: isActive && pickerState.isOpen },
)
// Expose handle to parent via ref
useImperativeHandle(
ref,
() => ({
pickerState,
handleItemSelect,
handleIndexChange: pickerActions.handleIndexChange,
closePicker: pickerActions.handleClose,
}),
[pickerState, handleItemSelect, pickerActions.handleIndexChange, pickerActions.handleClose],
)
return (
<MultilineTextInput
key={`autocomplete-input-${history.length}-${inputKeyCounter}`}
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
onEscape={handleEscape}
onUpAtFirstLine={navigateUp}
onDownAtLastLine={navigateDown}
placeholder={placeholder}
isActive={isActive}
showCursor={true}
prompt={prompt}
continuationIndent={continuationIndent}
columns={columns}
/>
)
}
/**
* A multiline text input with autocomplete support.
*
* Features:
* - Multiline text editing with history
* - Trigger-based autocomplete (e.g., @ for files, / for commands)
* - Keyboard navigation in picker
* - Exposes picker state via ref for external picker rendering
*
* @template T - The type of autocomplete items
*
* @example
* ```tsx
* const inputRef = useRef<AutocompleteInputHandle<MyItem>>(null)
*
* <AutocompleteInput
* ref={inputRef}
* triggers={myTriggers}
* onSubmit={handleSubmit}
* onPickerStateChange={(state) => setPickerState(state)}
* />
*
* {pickerState.isOpen && (
* <PickerSelect
* results={pickerState.results}
* selectedIndex={pickerState.selectedIndex}
* onSelect={inputRef.current?.handleItemSelect}
* // ...
* />
* )}
* ```
*/
export const AutocompleteInput = forwardRef(AutocompleteInputInner) as <T extends AutocompleteItem>(
props: AutocompleteInputProps<T> & { ref?: Ref<AutocompleteInputHandle<T>> },
) => ReturnType<typeof AutocompleteInputInner>
/**
* Re-export types and hook for convenience
*/
export { useAutocompletePicker } from "./useAutocompletePicker.js"
export type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"

View file

@ -0,0 +1,113 @@
import { useEffect, useReducer, type ReactNode } from "react"
import { Box, Text, useInput } from "ink"
import { ScrollArea } from "../ScrollArea.js"
import type { AutocompleteItem } from "./types.js"
export interface PickerSelectProps<T extends AutocompleteItem> {
/** Results to display in the picker */
results: T[]
/** Currently selected index */
selectedIndex: number
/** Maximum number of visible items */
maxVisible?: number
/** Called when an item is selected */
onSelect: (item: T) => void
/** Called when escape is pressed */
onEscape: () => void
/** Called when selection index changes */
onIndexChange: (index: number) => void
/** Render function for each item */
renderItem: (item: T, isSelected: boolean) => ReactNode
/** Message shown when results are empty */
emptyMessage?: string
/** Whether the picker accepts keyboard input */
isActive?: boolean
}
/**
* Generic picker dropdown component for autocomplete.
* Handles keyboard navigation and item selection.
*
* @template T - The type of items to display
*/
export function PickerSelect<T extends AutocompleteItem>({
results,
selectedIndex,
maxVisible = 10,
onSelect,
onEscape,
onIndexChange,
renderItem,
emptyMessage = "No results found",
isActive = true,
}: PickerSelectProps<T>) {
// Trigger for scrolling to the selected line
const [scrollTrigger, incrementScrollTrigger] = useReducer((x: number) => x + 1, 0)
// Scroll to selected item when selection changes
useEffect(() => {
incrementScrollTrigger()
}, [selectedIndex])
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>{emptyMessage}</Text>
</Box>
)
}
// Height for the scroll area - use maxVisible as the viewport height
const scrollHeight = Math.min(results.length, maxVisible)
return (
<ScrollArea
height={scrollHeight}
isActive={false}
showScrollbar={true}
scrollToLine={selectedIndex}
scrollToLineTrigger={scrollTrigger}>
{results.map((result, index) => {
const isSelected = index === selectedIndex
return <Box key={result.key}>{renderItem(result, isSelected)}</Box>
})}
</ScrollArea>
)
}

View file

@ -0,0 +1,56 @@
/**
* Autocomplete system for CLI input.
*
* This module provides a generic, extensible autocomplete system that supports
* multiple trigger patterns (like @ for files, / for commands) through a
* plugin-like trigger architecture.
*
* @example
* ```tsx
* import {
* AutocompleteInput,
* PickerSelect,
* useAutocompletePicker,
* createFileTrigger,
* createSlashCommandTrigger,
* } from './autocomplete'
*
* const triggers = [
* createFileTrigger({ onSearch, getResults }),
* createSlashCommandTrigger({ getCommands }),
* ]
*
* <AutocompleteInput
* triggers={triggers}
* onSubmit={handleSubmit}
* />
* ```
*/
// Main components
export { AutocompleteInput, type AutocompleteInputProps, type AutocompleteInputHandle } from "./AutocompleteInput.js"
export { PickerSelect, type PickerSelectProps } from "./PickerSelect.js"
// Hook
export { useAutocompletePicker } from "./useAutocompletePicker.js"
// Types
export type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"
// Triggers
export {
createFileTrigger,
toFileResult,
type FileResult,
type FileTriggerConfig,
createSlashCommandTrigger,
toSlashCommandResult,
type SlashCommandResult,
type SlashCommandTriggerConfig,
} from "./triggers/index.js"

View file

@ -0,0 +1,114 @@
import { Box, Text } from "ink"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* File search result type.
* Extends AutocompleteItem with file-specific properties.
*/
export interface FileResult extends AutocompleteItem {
/** File or folder path */
path: string
/** Whether this is a file or folder */
type: "file" | "folder"
/** Optional display label */
label?: string
}
/**
* Props for creating a file trigger
*/
export interface FileTriggerConfig {
/**
* Called when a search should be performed.
* This typically triggers an API call to search files.
*/
onSearch: (query: string) => void
/**
* Current search results from the store/API.
* Results are provided externally because file search is async.
*/
getResults: () => FileResult[]
}
/**
* Create a file trigger for @ mentions.
*
* This trigger activates when the user types @ followed by text,
* and allows selecting files to insert as @/path references.
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for file mentions
*/
export function createFileTrigger(config: FileTriggerConfig): AutocompleteTrigger<FileResult> {
const { onSearch, getResults } = config
return {
id: "file",
triggerChar: "@",
position: "anywhere",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Find the last @ in the line
const atIndex = lineText.lastIndexOf("@")
if (atIndex === -1) {
return null
}
// Extract query after @
const query = lineText.substring(atIndex + 1)
// Close picker if query contains space (user finished typing)
if (query.includes(" ")) {
return null
}
// Require at least one character after @
if (query.length === 0) {
return null
}
return { query, triggerIndex: atIndex }
},
search: (query: string): FileResult[] => {
// Trigger the external search
onSearch(query)
// Return current results from store
// Results will update asynchronously and trigger a re-render
return getResults()
},
renderItem: (item: FileResult, isSelected: boolean) => {
const displayPath = item.type === "folder" ? `${item.path}/` : item.path
return (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{displayPath}</Text>
</Box>
)
},
getReplacementText: (item: FileResult, lineText: string, triggerIndex: number): string => {
const beforeAt = lineText.substring(0, triggerIndex)
return `${beforeAt}@/${item.path} `
},
emptyMessage: "No matching files found",
debounceMs: 150,
}
}
/**
* Convert external FileSearchResult to FileResult.
* Use this to adapt results from the store to the trigger's expected type.
*/
export function toFileResult(result: { path: string; type: "file" | "folder"; label?: string }): FileResult {
return {
key: result.path,
path: result.path,
type: result.type,
label: result.label,
}
}

View file

@ -0,0 +1,135 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* Slash command result type.
* Extends AutocompleteItem with command-specific properties.
*/
export interface SlashCommandResult extends AutocompleteItem {
/** Command name (without the leading /) */
name: string
/** Optional description of what the command does */
description?: string
/** Optional hint about command arguments */
argumentHint?: string
/** Source of the command */
source: "global" | "project" | "built-in"
}
/**
* Props for creating a slash command trigger
*/
export interface SlashCommandTriggerConfig {
/**
* Get all available commands for filtering.
* Commands are filtered locally using fuzzy search.
*/
getCommands: () => SlashCommandResult[]
/**
* Maximum number of results to show.
* @default 20
*/
maxResults?: number
}
/**
* Create a slash command trigger for / commands.
*
* This trigger activates when the user types / at the start of a line,
* and allows selecting commands with local fuzzy filtering.
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for slash commands
*/
export function createSlashCommandTrigger(config: SlashCommandTriggerConfig): AutocompleteTrigger<SlashCommandResult> {
const { getCommands, maxResults = 20 } = config
return {
id: "slash-command",
triggerChar: "/",
position: "line-start",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with / (after optional whitespace)
const trimmed = lineText.trimStart()
if (!trimmed.startsWith("/")) {
return null
}
// Extract query after /
const query = trimmed.substring(1)
// Close picker if query contains space (command complete)
if (query.includes(" ")) {
return null
}
// Calculate trigger index (position of / in original line)
const triggerIndex = lineText.length - trimmed.length
return { query, triggerIndex }
},
search: (query: string): SlashCommandResult[] => {
const allCommands = getCommands()
if (query.length === 0) {
// Show all commands when just "/" is typed
return allCommands.slice(0, maxResults)
}
// Fuzzy search by command name
const results = fuzzysort.go(query, allCommands, {
key: "name",
limit: maxResults,
threshold: -10000, // Be lenient with matching
})
return results.map((result) => result.obj)
},
renderItem: (item: SlashCommandResult, isSelected: boolean) => {
// Source indicator icons
const sourceIcon = item.source === "built-in" ? "⚡" : item.source === "project" ? "📁" : "🌐"
return (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
{sourceIcon} /{item.name}
{item.description && <Text dimColor> - {item.description}</Text>}
</Text>
</Box>
)
},
getReplacementText: (item: SlashCommandResult, lineText: string, triggerIndex: number): string => {
const beforeSlash = lineText.substring(0, triggerIndex)
return `${beforeSlash}/${item.name} `
},
emptyMessage: "No matching commands found",
debounceMs: 150,
}
}
/**
* Convert external command data to SlashCommandResult.
* Use this to adapt commands from the store to the trigger's expected type.
*/
export function toSlashCommandResult(command: {
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
}): SlashCommandResult {
return {
key: command.name,
name: command.name,
description: command.description,
argumentHint: command.argumentHint,
source: command.source,
}
}

View file

@ -0,0 +1,12 @@
/**
* Autocomplete triggers for different trigger patterns.
*/
export { createFileTrigger, toFileResult, type FileResult, type FileTriggerConfig } from "./FileTrigger.js"
export {
createSlashCommandTrigger,
toSlashCommandResult,
type SlashCommandResult,
type SlashCommandTriggerConfig,
} from "./SlashCommandTrigger.js"

View file

@ -0,0 +1,127 @@
import type { ReactNode } from "react"
/**
* Represents a single autocomplete result item.
* All result types must extend this with a unique key.
*/
export interface AutocompleteItem {
/** Unique identifier for this item */
key: string
}
/**
* Result from trigger detection.
*/
export interface TriggerDetectionResult {
/** The search query extracted from the input */
query: string
/** Position of trigger character in the line */
triggerIndex: number
}
/**
* Configuration for an autocomplete trigger.
* Each trigger defines how to detect, search, and render autocomplete options.
*
* @template T - The type of items this trigger produces
*/
export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteItem> {
/**
* Unique identifier for this trigger.
* Used to track which trigger is active.
*/
id: string
/**
* The character(s) that activate this trigger.
* Examples: "@", "/", "#"
*/
triggerChar: string
/**
* Where the trigger must appear to activate.
* - 'anywhere': Can appear anywhere in the line (e.g., @ for file mentions)
* - 'line-start': Must be at start of line, optionally after whitespace (e.g., / for commands)
*/
position: "anywhere" | "line-start"
/**
* Detect if this trigger is active and extract the search query.
* @param lineText - The current line of text
* @returns Detection result with query and position, or null if trigger not active
*/
detectTrigger: (lineText: string) => TriggerDetectionResult | null
/**
* Search/filter results based on query.
* Can be synchronous (local filtering) or asynchronous (API call).
* @param query - The search query
* @returns Array of matching items
*/
search: (query: string) => T[] | Promise<T[]>
/**
* Render a single item in the picker dropdown.
* @param item - The item to render
* @param isSelected - Whether this item is currently selected
* @returns React node to render
*/
renderItem: (item: T, isSelected: boolean) => ReactNode
/**
* Generate the replacement text when an item is selected.
* @param item - The selected item
* @param lineText - The current line text
* @param triggerIndex - Position of trigger character in line
* @returns The new line text with selection inserted
*/
getReplacementText: (item: T, lineText: string, triggerIndex: number) => string
/**
* Message to show when no results match.
* @default "No results found"
*/
emptyMessage?: string
/**
* Debounce delay in milliseconds for search.
* @default 150
*/
debounceMs?: number
}
/**
* State for the active autocomplete picker.
*/
export interface AutocompletePickerState<T extends AutocompleteItem = AutocompleteItem> {
/** Which trigger is currently active (by id) */
activeTrigger: AutocompleteTrigger<T> | null
/** Current search results */
results: T[]
/** Currently selected index */
selectedIndex: number
/** Whether picker is visible */
isOpen: boolean
/** Loading state for async searches */
isLoading: boolean
/** The detected trigger info */
triggerInfo: TriggerDetectionResult | null
}
/**
* Actions returned by the useAutocompletePicker hook.
*/
export interface AutocompletePickerActions<T extends AutocompleteItem> {
/** Handle input value changes - detects triggers and initiates search */
handleInputChange: (value: string, lineText: string) => void
/** Handle item selection - returns the new input value */
handleSelect: (item: T, fullValue: string, lineText: string) => string
/** Close the picker */
handleClose: () => void
/** Update selected index */
handleIndexChange: (index: number) => void
/** Navigate selection up */
navigateUp: () => void
/** Navigate selection down */
navigateDown: () => void
}

View file

@ -0,0 +1,249 @@
import { useState, useCallback, useRef, useEffect } from "react"
import type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"
const DEFAULT_DEBOUNCE_MS = 150
/**
* Hook that manages autocomplete picker state and logic.
*
* @template T - The type of autocomplete items
* @param triggers - Array of autocomplete triggers to check
* @returns Picker state and actions
*/
export function useAutocompletePicker<T extends AutocompleteItem>(
triggers: AutocompleteTrigger<T>[],
): [AutocompletePickerState<T>, AutocompletePickerActions<T>] {
const [state, setState] = useState<AutocompletePickerState<T>>({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
// Debounce timer refs for each trigger
const debounceTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
const lastQueriesRef = useRef<Map<string, string>>(new Map())
// Cleanup debounce timers on unmount
useEffect(() => {
return () => {
debounceTimersRef.current.forEach((timer) => clearTimeout(timer))
}
}, [])
/**
* Get the last line from the input value
*/
const getLastLine = useCallback((value: string): string => {
const lines = value.split("\n")
return lines[lines.length - 1] || ""
}, [])
/**
* Handle input value changes - detects triggers and initiates search
*/
const handleInputChange = useCallback(
(value: string, lineText?: string) => {
const lastLine = lineText ?? getLastLine(value)
// Check each trigger for activation
let foundTrigger: AutocompleteTrigger<T> | null = null
let foundTriggerInfo: TriggerDetectionResult | null = null
for (const trigger of triggers) {
const detection = trigger.detectTrigger(lastLine)
if (detection) {
foundTrigger = trigger
foundTriggerInfo = detection
break
}
}
// No trigger found - close picker
if (!foundTrigger || !foundTriggerInfo) {
if (state.isOpen) {
setState((prev) => ({
...prev,
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
}))
}
return
}
const { query } = foundTriggerInfo
const debounceMs = foundTrigger.debounceMs ?? DEFAULT_DEBOUNCE_MS
// Clear existing debounce timer for this trigger
const existingTimer = debounceTimersRef.current.get(foundTrigger.id)
if (existingTimer) {
clearTimeout(existingTimer)
}
// Check if query has changed
const lastQuery = lastQueriesRef.current.get(foundTrigger.id)
if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) {
// Same query, same trigger - no need to search again
return
}
// Set loading state immediately
setState((prev) => ({
...prev,
activeTrigger: foundTrigger,
isLoading: true,
triggerInfo: foundTriggerInfo,
}))
// Debounce the search
const timer = setTimeout(async () => {
lastQueriesRef.current.set(foundTrigger.id, query)
try {
const results = await foundTrigger.search(query)
setState((prev) => {
// Only update if this is still the active trigger
if (prev.activeTrigger?.id !== foundTrigger.id) {
return prev
}
return {
...prev,
results,
selectedIndex: 0,
isOpen: results.length > 0 || query.length > 0,
isLoading: false,
}
})
} catch (_error) {
// On error, close picker
setState((prev) => ({
...prev,
results: [],
isOpen: false,
isLoading: false,
}))
}
}, debounceMs)
debounceTimersRef.current.set(foundTrigger.id, timer)
},
[triggers, state.isOpen, state.activeTrigger?.id, getLastLine],
)
/**
* Handle item selection - returns the new input value with the selection inserted
*/
const handleSelect = useCallback(
(item: T, fullValue: string, lineText?: string): string => {
const { activeTrigger, triggerInfo } = state
if (!activeTrigger || !triggerInfo) {
return fullValue
}
// Get the lines
const lines = fullValue.split("\n")
const lastLineIndex = lines.length - 1
const lastLine = lineText ?? lines[lastLineIndex] ?? ""
// Get replacement text from trigger
const newLastLine = activeTrigger.getReplacementText(item, lastLine, triggerInfo.triggerIndex)
// Replace the last line
lines[lastLineIndex] = newLastLine
const newValue = lines.join("\n")
// Reset state
setState({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
// Clear last query for this trigger
lastQueriesRef.current.delete(activeTrigger.id)
return newValue
},
[state],
)
/**
* Close the picker
*/
const handleClose = useCallback(() => {
// Clear any pending debounce timers
debounceTimersRef.current.forEach((timer) => clearTimeout(timer))
debounceTimersRef.current.clear()
setState({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
}, [])
/**
* Update selected index
*/
const handleIndexChange = useCallback((index: number) => {
setState((prev) => ({
...prev,
selectedIndex: index,
}))
}, [])
/**
* Navigate selection up (with wrap-around)
*/
const navigateUp = useCallback(() => {
setState((prev) => {
if (prev.results.length === 0) return prev
const newIndex = prev.selectedIndex > 0 ? prev.selectedIndex - 1 : prev.results.length - 1
return { ...prev, selectedIndex: newIndex }
})
}, [])
/**
* Navigate selection down (with wrap-around)
*/
const navigateDown = useCallback(() => {
setState((prev) => {
if (prev.results.length === 0) return prev
const newIndex = prev.selectedIndex < prev.results.length - 1 ? prev.selectedIndex + 1 : 0
return { ...prev, selectedIndex: newIndex }
})
}, [])
const actions: AutocompletePickerActions<T> = {
handleInputChange,
handleSelect,
handleClose,
handleIndexChange,
navigateUp,
navigateDown,
}
return [state, actions]
}

View file

@ -0,0 +1,38 @@
/**
* TerminalSizeContext - Provides terminal dimensions via React Context
* This ensures only one instance of useTerminalSize exists in the app
*/
import { createContext, useContext, ReactNode } from "react"
import { useTerminalSize as useTerminalSizeHook } from "./useTerminalSize.js"
interface TerminalSizeContextValue {
columns: number
rows: number
}
const TerminalSizeContext = createContext<TerminalSizeContextValue | null>(null)
interface TerminalSizeProviderProps {
children: ReactNode
}
/**
* Provider component that wraps the app and provides terminal size to all children
*/
export function TerminalSizeProvider({ children }: TerminalSizeProviderProps) {
const size = useTerminalSizeHook()
return <TerminalSizeContext.Provider value={size}>{children}</TerminalSizeContext.Provider>
}
/**
* Hook to access terminal size from context
* Must be used within a TerminalSizeProvider
*/
export function useTerminalSize(): TerminalSizeContextValue {
const context = useContext(TerminalSizeContext)
if (!context) {
throw new Error("useTerminalSize must be used within a TerminalSizeProvider")
}
return context
}

View file

@ -1,99 +1,24 @@
/**
* useInputHistory Hook
*
* Provides input history navigation for CLI text inputs.
* Navigation is triggered via navigateUp/navigateDown functions.
* History is persisted to ~/.roo/cli-history.json
*/
import { useState, useEffect, useCallback, useRef } from "react"
import { loadHistory, addToHistory } from "../../utils/historyStorage.js"
export interface UseInputHistoryOptions {
/**
* Whether the hook should respond to navigation calls.
* Set to false when input is not active/focused.
* @default true
*/
isActive?: boolean
/**
* Callback to get the current input value when starting to browse history.
* This allows saving the user's draft before navigating.
*/
getCurrentInput?: () => string
}
export interface UseInputHistoryReturn {
/**
* Add a new entry to history (call on submit)
*/
addEntry: (entry: string) => Promise<void>
/**
* Current history value being browsed, or null if not browsing.
* Use this value to display in the input when browsing history.
*/
historyValue: string | null
/**
* Whether currently browsing through history
*/
isBrowsing: boolean
/**
* Reset browsing state and optionally save current input as draft.
* Call this when user starts typing.
*/
resetBrowsing: (currentInput?: string) => void
/**
* All history entries (oldest first)
*/
history: string[]
/**
* The saved draft (what user was typing before navigating history)
*/
draft: string
/**
* 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
}
/**
* Hook for managing input history with up/down arrow navigation
*
* @example
* ```tsx
* const { addEntry, historyValue, draft, setDraft } = useInputHistory({ isActive: true });
*
* // Track current input via onChange
* <TextInput onChange={setDraft} ... />
*
* // When user submits input:
* const handleSubmit = async (text: string) => {
* await addEntry(text);
* // ... handle submission
* };
*
* // Use historyValue to control input:
* // If historyValue is not null, display it instead of current input
* ```
*/
export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputHistoryReturn {
const { isActive = true, getCurrentInput } = options

View file

@ -15,6 +15,7 @@ interface TerminalSize {
* Debounces resize events to prevent rendering artifacts
*/
export function useTerminalSize(): TerminalSize {
// Get initial size synchronously - this is the value used for first render
const [size, setSize] = useState<TerminalSize>(() => ({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,

View file

@ -5,7 +5,25 @@ export { type TUIAppProps, App } from "./App.js"
export { default as Header } from "./components/Header.js"
export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js"
export { default as LoadingText } from "./components/LoadingText.js"
export { TextInput, ApprovalPrompt } from "./components/TextInput.js"
// Autocomplete system
export {
AutocompleteInput,
PickerSelect,
useAutocompletePicker,
createFileTrigger,
createSlashCommandTrigger,
toFileResult,
toSlashCommandResult,
type AutocompleteInputProps,
type AutocompleteInputHandle,
type AutocompleteItem,
type AutocompleteTrigger,
type AutocompletePickerState,
type PickerSelectProps,
type FileResult,
type SlashCommandResult as AutocompleteSlashCommandResult,
} from "./components/autocomplete/index.js"
// Hooks
export { useInputHistory } from "./hooks/useInputHistory.js"

View file

@ -1,34 +1,46 @@
import { create } from "zustand"
import type { TUIMessage, PendingAsk, FileSearchResult } from "./types.js"
import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult } from "./types.js"
/**
* CLI application state.
*
* Note: Autocomplete picker UI state (isOpen, selectedIndex) is now managed
* by the useAutocompletePicker hook. The store only holds data that needs
* to be shared between components or persisted (like search results from API).
*/
interface CLIState {
// Message history
messages: TUIMessage[]
pendingAsk: PendingAsk | null
// Task state
isLoading: boolean
isComplete: boolean
hasStartedTask: boolean
error: string | null
// Autocomplete data (from API/extension)
fileSearchResults: FileSearchResult[]
isFilePickerOpen: boolean
filePickerQuery: string
filePickerSelectedIndex: number
allSlashCommands: SlashCommandResult[]
}
interface CLIActions {
// Message actions
addMessage: (msg: TUIMessage) => void
updateMessage: (id: string, content: string, partial?: boolean) => void
// Task actions
setPendingAsk: (ask: PendingAsk | null) => void
setLoading: (loading: boolean) => void
setComplete: (complete: boolean) => void
setHasStartedTask: (started: boolean) => void
setError: (error: string | null) => void
reset: () => void
// Autocomplete data actions
setFileSearchResults: (results: FileSearchResult[]) => void
setFilePickerOpen: (open: boolean) => void
setFilePickerQuery: (query: string) => void
setFilePickerSelectedIndex: (index: number) => void
clearFilePicker: () => void
setAllSlashCommands: (commands: SlashCommandResult[]) => void
}
const initialState: CLIState = {
@ -39,9 +51,7 @@ const initialState: CLIState = {
hasStartedTask: false,
error: null,
fileSearchResults: [],
isFilePickerOpen: false,
filePickerQuery: "",
filePickerSelectedIndex: 0,
allSlashCommands: [],
}
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
@ -94,15 +104,6 @@ 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,
}),
setFileSearchResults: (results) => set({ fileSearchResults: results }),
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
}))

View file

@ -63,6 +63,7 @@ export interface AppProps {
debug: boolean
exitOnComplete: boolean
reasoningEffort?: string
version: string
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
@ -72,3 +73,10 @@ export interface FileSearchResult {
type: "file" | "folder"
label?: string
}
export interface SlashCommandResult {
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
}

View file

@ -65,8 +65,10 @@ export const thinkingText = catppuccin.overlay2 // Subtle gray for thinking text
// UI element colors
export const borderColor = catppuccin.surface1 // Surface color for borders
export const borderColorActive = catppuccin.blue // Active/focused border color
export const dimText = catppuccin.overlay1 // Dim text
export const promptColor = catppuccin.overlay2 // Prompt indicator
export const promptColorActive = catppuccin.blue // Active prompt color
export const placeholderColor = catppuccin.overlay0 // Placeholder text
// Status colors
@ -74,5 +76,9 @@ export const successColor = catppuccin.green // Green for success
export const errorColor = catppuccin.red // Red for errors
export const warningColor = catppuccin.yellow // Yellow for warnings
// Focus indicator colors
export const focusColor = catppuccin.blue // Focus indicator (blue accent)
export const scrollActiveColor = catppuccin.mauve // Scroll area active indicator (purple)
// Base text color
export const text = catppuccin.text // Standard text color

4
pnpm-lock.yaml generated
View file

@ -97,6 +97,9 @@ importers:
commander:
specifier: ^12.1.0
version: 12.1.0
fuzzysort:
specifier: ^3.1.0
version: 3.1.0
ink:
specifier: ^6.6.0
version: 6.6.0(@types/react@18.3.23)(react@19.2.3)
@ -10296,6 +10299,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}

View file

@ -191,7 +191,9 @@ export interface ExtensionMessage {
values?: Record<string, any>
requestId?: string
promptText?: string
results?: { path: string; type: "file" | "folder"; label?: string }[]
results?:
| { path: string; type: "file" | "folder"; label?: string }[]
| { name: string; description?: string; argumentHint?: string; source: "global" | "project" | "built-in" }[]
error?: string
setting?: string
value?: any