From 262aaa52d1ce09e962c8c1878e429daa4312b822 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 6 Jan 2026 15:39:45 -0800 Subject: [PATCH] More progress --- apps/cli/README.md | 13 +- apps/cli/install.sh | 2 +- apps/cli/package.json | 1 + .../cli/src/__tests__/FilePickerInput.test.ts | 255 -------- .../src/__tests__/FilePickerSelect.test.ts | 218 ------- apps/cli/src/__tests__/ScrollArea.test.ts | 472 ++++++++++++++ .../autocomplete/FileTrigger.test.ts | 111 ++++ .../autocomplete/SlashCommandTrigger.test.ts | 161 +++++ apps/cli/src/index.ts | 22 +- apps/cli/src/ui/App.tsx | 590 +++++++++++------- .../cli/src/ui/components/ChatHistoryItem.tsx | 14 +- .../cli/src/ui/components/FilePickerInput.tsx | 229 ------- .../src/ui/components/FilePickerSelect.tsx | 108 ---- apps/cli/src/ui/components/Header.tsx | 13 +- .../src/ui/components/MultilineTextInput.tsx | 209 +++++-- apps/cli/src/ui/components/ScrollArea.tsx | 382 ++++++++++++ .../cli/src/ui/components/ScrollIndicator.tsx | 26 + apps/cli/src/ui/components/TextInput.tsx | 162 ----- .../autocomplete/AutocompleteInput.tsx | 281 +++++++++ .../components/autocomplete/PickerSelect.tsx | 113 ++++ .../src/ui/components/autocomplete/index.ts | 56 ++ .../autocomplete/triggers/FileTrigger.tsx | 114 ++++ .../triggers/SlashCommandTrigger.tsx | 135 ++++ .../components/autocomplete/triggers/index.ts | 12 + .../src/ui/components/autocomplete/types.ts | 127 ++++ .../autocomplete/useAutocompletePicker.ts | 249 ++++++++ apps/cli/src/ui/hooks/TerminalSizeContext.tsx | 38 ++ apps/cli/src/ui/hooks/useInputHistory.ts | 75 --- apps/cli/src/ui/hooks/useTerminalSize.ts | 1 + apps/cli/src/ui/index.ts | 20 +- apps/cli/src/ui/store.ts | 45 +- apps/cli/src/ui/types.ts | 8 + apps/cli/src/ui/utils/theme.ts | 6 + pnpm-lock.yaml | 4 + src/shared/ExtensionMessage.ts | 4 +- 35 files changed, 2903 insertions(+), 1373 deletions(-) delete mode 100644 apps/cli/src/__tests__/FilePickerInput.test.ts delete mode 100644 apps/cli/src/__tests__/FilePickerSelect.test.ts create mode 100644 apps/cli/src/__tests__/ScrollArea.test.ts create mode 100644 apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts create mode 100644 apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts delete mode 100644 apps/cli/src/ui/components/FilePickerInput.tsx delete mode 100644 apps/cli/src/ui/components/FilePickerSelect.tsx create mode 100644 apps/cli/src/ui/components/ScrollArea.tsx create mode 100644 apps/cli/src/ui/components/ScrollIndicator.tsx delete mode 100644 apps/cli/src/ui/components/TextInput.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/PickerSelect.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/index.ts create mode 100644 apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/triggers/index.ts create mode 100644 apps/cli/src/ui/components/autocomplete/types.ts create mode 100644 apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts create mode 100644 apps/cli/src/ui/hooks/TerminalSizeContext.tsx diff --git a/apps/cli/README.md b/apps/cli/README.md index 3e78192d4e..867f3a94a5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 ` | Workspace path to operate in | Current directory | +| `[workspace]` | Workspace path to operate in (positional argument) | Current directory | +| `-P, --prompt ` | The prompt/task to execute (optional in TUI mode) | None | | `-e, --extension ` | 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` | diff --git a/apps/cli/install.sh b/apps/cli/install.sh index ca82ecfdd8..9980d41d42 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -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 "" } diff --git a/apps/cli/package.json b/apps/cli/package.json index 155a93f197..73420e8a2d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" diff --git a/apps/cli/src/__tests__/FilePickerInput.test.ts b/apps/cli/src/__tests__/FilePickerInput.test.ts deleted file mode 100644 index ad807c8181..0000000000 --- a/apps/cli/src/__tests__/FilePickerInput.test.ts +++ /dev/null @@ -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) - }) - }) -}) diff --git a/apps/cli/src/__tests__/FilePickerSelect.test.ts b/apps/cli/src/__tests__/FilePickerSelect.test.ts deleted file mode 100644 index ba2e7110ef..0000000000 --- a/apps/cli/src/__tests__/FilePickerSelect.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -let _mockInputHandler: ((input: string, key: Record) => 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) => 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" }) - }) - }) -}) diff --git a/apps/cli/src/__tests__/ScrollArea.test.ts b/apps/cli/src/__tests__/ScrollArea.test.ts new file mode 100644 index 0000000000..ff54edd5de --- /dev/null +++ b/apps/cli/src/__tests__/ScrollArea.test.ts @@ -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) + }) +}) diff --git a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts new file mode 100644 index 0000000000..1fab6147d7 --- /dev/null +++ b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts @@ -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) + }) + }) +}) diff --git a/apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts b/apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts new file mode 100644 index 0000000000..2319d8babf --- /dev/null +++ b/apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts @@ -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: "", + source: "project" as const, + } + const result = toSlashCommandResult(input) + + expect(result).toEqual({ + key: "mode", + name: "mode", + description: "Switch mode", + argumentHint: "", + 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) + }) + }) +}) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index a67faf70fa..1eead41a17 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -42,8 +42,8 @@ program .version(packageJson.version) program - .argument("[prompt]", "The prompt/task to execute (optional in TUI mode)") - .option("-w, --workspace ", "Workspace path to operate in", process.cwd()) + .argument("[workspace]", "Workspace path to operate in", process.cwd()) + .option("-P, --prompt ", "The prompt/task to execute (optional in TUI mode)") .option("-e, --extension ", "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 [options]") + console.error("[CLI] Usage: roo [workspace] -P [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) { diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 6b20ef3f7f..fe0fc38633 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -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 {"─".repeat(columns)} + const color = active ? theme.borderColorActive : theme.borderColor + return {"─".repeat(columns)} } /** - * 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(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const autocompleteRef = useRef>(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const followupAutocompleteRef = useRef>(null) // Track seen message timestamps to filter duplicates and the prompt echo const seenMessageIds = useRef>(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>({ + 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[] => { + 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 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) => { + 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 ? ( Press Ctrl+C again to exit - ) : ( - ↑↓ history • ? for shortcuts - ) + ) : isLoading ? ( + + {view === "ToolUse" ? "Using tool" : "Thinking"} + {isScrollAreaActive && ( + <> + + + + )} + + ) : isScrollAreaActive ? ( + + ) : 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) => ( + + {item.key} + + ) + } return ( - - {/* Header with ASCII art */} -
+ + {/* Header - fixed size */} + +
+ - {/* Message history - render all completed messages */} - {displayMessages.map((message) => ( - - ))} + {/* Scrollable message history area - fills remaining space via flexGrow */} + + {displayMessages.map((message) => ( + + ))} + - {/* Input area - with borders like Claude Code */} - - {view === "UserInput" ? ( - pendingAsk?.type === "followup" ? ( - - {pendingAsk.content} - {pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? ( - - - ({ + 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) - } - }} - /> - - ↑↓ navigate • Enter select - - ) : ( - - - { - // 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="> " - /> - - {statusBarMessage} - - )} - - ) : pendingAsk ? ( - - {pendingAsk.content} - - Press Y to approve,{" "} - N to reject - - - ) : isComplete ? ( - - - - - {!isFilePickerOpen && statusBarMessage} - {isFilePickerOpen && ( - - )} - - ) : ( - - - - - {!isFilePickerOpen && statusBarMessage} - {isFilePickerOpen && ( - + ↑↓ navigate • Enter select + + ) : ( + + + { + if (text && text.trim()) { + handleSubmit(text) + setShowCustomInput(false) + isTransitioningToCustomInput.current = false + } + }} + isActive={true} + triggers={autocompleteTriggers} + onPickerStateChange={handlePickerStateChange} + prompt="> " /> - )} - - ) - ) : view === "ToolUse" ? ( - - Using tool + + {pickerState.isOpen ? ( + + + + ) : ( + {statusBarMessage} + )} + + )} + + ) : showApprovalPrompt ? ( + + {pendingAsk?.content} + + Press Y to approve,{" "} + N to reject + + {statusBarMessage} ) : ( - - Thinking + + + + + {pickerState.isOpen ? ( + + + + ) : ( + {statusBarMessage} + )} )} @@ -853,13 +956,23 @@ export function App({ ) } +/** + * Main TUI Application Component - wraps with TerminalSizeProvider + */ +export function App(props: TUIAppProps) { + return ( + + + + ) +} + /** * Format tool output for display (used in the message body, header shows tool name separately) */ function formatToolOutput(toolInfo: Record): 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 { } 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 { function formatToolAskMessage(toolInfo: Record): 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 { } default: { - // Generic formatting for other tools const params = Object.entries(toolInfo) .filter(([key]) => key !== "tool") .map(([key, value]) => { diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 7cd04d9c92..86fac28ae6 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -48,18 +48,28 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { ) - 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 ( {`tool - ${message.toolDisplayName || message.toolName || "unknown"}`} - {message.toolDisplayOutput || content} + {toolContent} ) + } case "system": // System messages are typically rendered as Header, not here. // But if they appear, show them subtly. diff --git a/apps/cli/src/ui/components/FilePickerInput.tsx b/apps/cli/src/ui/components/FilePickerInput.tsx deleted file mode 100644 index 8543948954..0000000000 --- a/apps/cli/src/ui/components/FilePickerInput.tsx +++ /dev/null @@ -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(null) - const lastSearchQueryRef = useRef(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 ( - - ) -} diff --git a/apps/cli/src/ui/components/FilePickerSelect.tsx b/apps/cli/src/ui/components/FilePickerSelect.tsx deleted file mode 100644 index a355997c15..0000000000 --- a/apps/cli/src/ui/components/FilePickerSelect.tsx +++ /dev/null @@ -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 ( - - No matching files found - - ) - } - - return ( - - {visibleResults.map((result, visibleIndex) => { - const actualIndex = scrollOffset + visibleIndex - const isSelected = actualIndex === selectedIndex - const displayPath = result.type === "folder" ? `${result.path}/` : result.path - - return ( - - {displayPath} - - ) - })} - - ) -} diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index ae643887bf..9625193425 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -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 {"─".repeat(columns)} -} -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 - + {/* Inline horizontal line using the same columns value */} + {"─".repeat(columns)} ) } diff --git a/apps/cli/src/ui/components/MultilineTextInput.tsx b/apps/cli/src/ui/components/MultilineTextInput.tsx index 2b367554d6..1dc255f95f 100644 --- a/apps/cli/src/ui/components/MultilineTextInput.tsx +++ b/apps/cli/src/ui/components/MultilineTextInput.tsx @@ -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 ( - - {linePrefix} + + {linePrefix || padding} {beforeCursor} {cursorChar} {afterCursor} @@ -272,14 +409,14 @@ export function MultilineTextInput({ } return ( - - {linePrefix} - {lineText} + + {linePrefix || padding} + {row.text} ) }, - [prompt, continuationIndent, cursorPosition, value, isActive], + [prompt, continuationIndent, cursorPosition, value, isActive, visualRows], ) - return {lines.map((line, index) => renderLine(line, index))} + return {visualRows.map((row, index) => renderVisualRow(row, index))} } diff --git a/apps/cli/src/ui/components/ScrollArea.tsx b/apps/cli/src/ui/components/ScrollArea.tsx new file mode 100644 index 0000000000..bc0a552a1b --- /dev/null +++ b/apps/cli/src/ui/components/ScrollArea.tsx @@ -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(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(null) + const lastMeasuredHeight = useRef(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 ( + + {/* Scroll content area */} + + + {children} + + + + {/* Scrollbar - rendered as a single Text element to avoid per-character wrapping issues */} + {showScrollbar && ( + + {showScrollbarVisible && height > 0 && ( + + {Array(height) + .fill(null) + .map((_, i) => { + const isHandle = + i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight + return isHandle ? "█" : "░" + }) + .join("\n")} + + )} + + )} + + ) +} + +/** + * 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, + } +} diff --git a/apps/cli/src/ui/components/ScrollIndicator.tsx b/apps/cli/src/ui/components/ScrollIndicator.tsx new file mode 100644 index 0000000000..2351e81000 --- /dev/null +++ b/apps/cli/src/ui/components/ScrollIndicator.tsx @@ -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 ( + + {percentage}% • ↑↓ scroll • Ctrl+E end + + ) +} + +export default memo(ScrollIndicator) diff --git a/apps/cli/src/ui/components/TextInput.tsx b/apps/cli/src/ui/components/TextInput.tsx deleted file mode 100644 index ae8e8bebb0..0000000000 --- a/apps/cli/src/ui/components/TextInput.tsx +++ /dev/null @@ -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 ( - - - {placeholder} - - - ) - } - - return ( - - - - ) -} - -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 ( - - - {message} - - - {suggestions && suggestions.length > 0 && ( - - Suggestions: - {suggestions.map((suggestion, index) => ( - - - {index + 1}. {suggestion.answer} - {suggestion.mode && ( - - {" "} - (mode: {suggestion.mode}) - - )} - - - ))} - - )} - - - {onTextResponse ? ( - - - Type a number (1-{suggestions?.length || 0}), type your answer, or press{" "} - - Y - - / - - N - - - - { - if (val.trim()) { - onTextResponse(val.trim()) - } - }} - /> - - - ) : ( - - Press{" "} - - Y - {" "} - to approve,{" "} - - N - {" "} - to reject - - )} - - - ) -} diff --git a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx new file mode 100644 index 0000000000..5fd9fd0f83 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx @@ -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 { + /** 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[] + /** 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) => 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 { + /** Current picker state */ + pickerState: AutocompletePickerState + /** 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( + { + placeholder = "Type your message...", + onSubmit, + isActive = true, + triggers, + onSelect, + onPickerStateChange, + prompt = "> ", + continuationIndent = " ", + }: AutocompleteInputProps, + ref: Ref>, +) { + 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 ( + + ) +} + +/** + * 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>(null) + * + * setPickerState(state)} + * /> + * + * {pickerState.isOpen && ( + * + * )} + * ``` + */ +export const AutocompleteInput = forwardRef(AutocompleteInputInner) as ( + props: AutocompleteInputProps & { ref?: Ref> }, +) => ReturnType + +/** + * Re-export types and hook for convenience + */ +export { useAutocompletePicker } from "./useAutocompletePicker.js" +export type { + AutocompleteItem, + AutocompleteTrigger, + AutocompletePickerState, + AutocompletePickerActions, + TriggerDetectionResult, +} from "./types.js" diff --git a/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx new file mode 100644 index 0000000000..ba23e0a061 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx @@ -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 { + /** 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({ + results, + selectedIndex, + maxVisible = 10, + onSelect, + onEscape, + onIndexChange, + renderItem, + emptyMessage = "No results found", + isActive = true, +}: PickerSelectProps) { + // 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 ( + + {emptyMessage} + + ) + } + + // Height for the scroll area - use maxVisible as the viewport height + const scrollHeight = Math.min(results.length, maxVisible) + + return ( + + {results.map((result, index) => { + const isSelected = index === selectedIndex + return {renderItem(result, isSelected)} + })} + + ) +} diff --git a/apps/cli/src/ui/components/autocomplete/index.ts b/apps/cli/src/ui/components/autocomplete/index.ts new file mode 100644 index 0000000000..1148323936 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/index.ts @@ -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 }), + * ] + * + * + * ``` + */ + +// 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" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx new file mode 100644 index 0000000000..9108211c56 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx @@ -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 { + 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 ( + + {displayPath} + + ) + }, + + 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, + } +} diff --git a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx new file mode 100644 index 0000000000..0cb9400a43 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx @@ -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 { + 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 ( + + + {sourceIcon} /{item.name} + {item.description && - {item.description}} + + + ) + }, + + 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, + } +} diff --git a/apps/cli/src/ui/components/autocomplete/triggers/index.ts b/apps/cli/src/ui/components/autocomplete/triggers/index.ts new file mode 100644 index 0000000000..d172d0b4fd --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/index.ts @@ -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" diff --git a/apps/cli/src/ui/components/autocomplete/types.ts b/apps/cli/src/ui/components/autocomplete/types.ts new file mode 100644 index 0000000000..d3ab41cf2b --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/types.ts @@ -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 { + /** + * 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 + + /** + * 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 { + /** Which trigger is currently active (by id) */ + activeTrigger: AutocompleteTrigger | 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 { + /** 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 +} diff --git a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts new file mode 100644 index 0000000000..a390ec3ce4 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts @@ -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( + triggers: AutocompleteTrigger[], +): [AutocompletePickerState, AutocompletePickerActions] { + const [state, setState] = useState>({ + activeTrigger: null, + results: [], + selectedIndex: 0, + isOpen: false, + isLoading: false, + triggerInfo: null, + }) + + // Debounce timer refs for each trigger + const debounceTimersRef = useRef>(new Map()) + const lastQueriesRef = useRef>(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 | 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 = { + handleInputChange, + handleSelect, + handleClose, + handleIndexChange, + navigateUp, + navigateDown, + } + + return [state, actions] +} diff --git a/apps/cli/src/ui/hooks/TerminalSizeContext.tsx b/apps/cli/src/ui/hooks/TerminalSizeContext.tsx new file mode 100644 index 0000000000..c8718abb97 --- /dev/null +++ b/apps/cli/src/ui/hooks/TerminalSizeContext.tsx @@ -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(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 {children} +} + +/** + * 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 +} diff --git a/apps/cli/src/ui/hooks/useInputHistory.ts b/apps/cli/src/ui/hooks/useInputHistory.ts index 3a698f43ca..ae57bffdda 100644 --- a/apps/cli/src/ui/hooks/useInputHistory.ts +++ b/apps/cli/src/ui/hooks/useInputHistory.ts @@ -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 - - /** - * 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 - * - * - * // 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 diff --git a/apps/cli/src/ui/hooks/useTerminalSize.ts b/apps/cli/src/ui/hooks/useTerminalSize.ts index 4c3415f922..01ea262f6a 100644 --- a/apps/cli/src/ui/hooks/useTerminalSize.ts +++ b/apps/cli/src/ui/hooks/useTerminalSize.ts @@ -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(() => ({ columns: process.stdout.columns || 80, rows: process.stdout.rows || 24, diff --git a/apps/cli/src/ui/index.ts b/apps/cli/src/ui/index.ts index 1648fff20a..017fd12b38 100644 --- a/apps/cli/src/ui/index.ts +++ b/apps/cli/src/ui/index.ts @@ -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" diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index 28307b5d93..8a67da4590 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -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((set) => ({ @@ -94,15 +104,6 @@ export const useCLIStore = create((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 }), })) diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index 317cb32850..1b19145dce 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -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" +} diff --git a/apps/cli/src/ui/utils/theme.ts b/apps/cli/src/ui/utils/theme.ts index 8c52cfd714..abcd2f26b0 100644 --- a/apps/cli/src/ui/utils/theme.ts +++ b/apps/cli/src/ui/utils/theme.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f00e4152e8..4aa5aae148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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==} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c8..c09d08dd58 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -191,7 +191,9 @@ export interface ExtensionMessage { values?: Record 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