mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
More progress
This commit is contained in:
parent
0a8f1a13c4
commit
262aaa52d1
35 changed files with 2903 additions and 1373 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
472
apps/cli/src/__tests__/ScrollArea.test.ts
Normal file
472
apps/cli/src/__tests__/ScrollArea.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
111
apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts
Normal file
111
apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
161
apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts
Normal file
161
apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
382
apps/cli/src/ui/components/ScrollArea.tsx
Normal file
382
apps/cli/src/ui/components/ScrollArea.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
26
apps/cli/src/ui/components/ScrollIndicator.tsx
Normal file
26
apps/cli/src/ui/components/ScrollIndicator.tsx
Normal 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)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
281
apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx
Normal file
281
apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx
Normal 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"
|
||||
113
apps/cli/src/ui/components/autocomplete/PickerSelect.tsx
Normal file
113
apps/cli/src/ui/components/autocomplete/PickerSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/cli/src/ui/components/autocomplete/index.ts
Normal file
56
apps/cli/src/ui/components/autocomplete/index.ts
Normal 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"
|
||||
114
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal file
114
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
12
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal file
12
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal 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"
|
||||
127
apps/cli/src/ui/components/autocomplete/types.ts
Normal file
127
apps/cli/src/ui/components/autocomplete/types.ts
Normal 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
|
||||
}
|
||||
249
apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts
Normal file
249
apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts
Normal 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]
|
||||
}
|
||||
38
apps/cli/src/ui/hooks/TerminalSizeContext.tsx
Normal file
38
apps/cli/src/ui/hooks/TerminalSizeContext.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4
pnpm-lock.yaml
generated
|
|
@ -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==}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue