From c9d52349d57a2a710dcfd5e8349119132450afd8 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 6 Jan 2026 21:28:27 -0800 Subject: [PATCH] More progress --- .roo/rules-debug/cli.md | 67 ++++++ apps/cli/package.json | 1 + apps/cli/src/__tests__/ScrollArea.test.ts | 13 ++ .../autocomplete/FileTrigger.test.ts | 87 +++++++- .../autocomplete/ModeTrigger.test.ts | 165 +++++++++++++++ apps/cli/src/extension-host.ts | 5 + apps/cli/src/ui/App.tsx | 199 ++++++++++++++++-- .../cli/src/ui/components/ChatHistoryItem.tsx | 17 ++ apps/cli/src/ui/components/Icon.tsx | 144 +++++++++++++ apps/cli/src/ui/components/ScrollArea.tsx | 5 +- .../src/ui/components/TodoChangeDisplay.tsx | 142 +++++++++++++ apps/cli/src/ui/components/TodoDisplay.tsx | 179 ++++++++++++++++ .../src/ui/components/__tests__/Icon.test.tsx | 162 ++++++++++++++ .../__tests__/TodoChangeDisplay.test.tsx | 149 +++++++++++++ .../components/__tests__/TodoDisplay.test.tsx | 138 ++++++++++++ .../autocomplete/AutocompleteInput.tsx | 19 +- .../components/autocomplete/PickerSelect.tsx | 3 +- .../src/ui/components/autocomplete/index.ts | 4 + .../triggers/FileTrigger.test.tsx | 168 +++++++++++++++ .../autocomplete/triggers/FileTrigger.tsx | 48 ++++- .../autocomplete/triggers/ModeTrigger.tsx | 128 +++++++++++ .../components/autocomplete/triggers/index.ts | 2 + .../src/ui/components/autocomplete/types.ts | 11 + .../autocomplete/useAutocompletePicker.ts | 75 ++++++- apps/cli/src/ui/store.ts | 30 ++- apps/cli/src/ui/types.ts | 16 +- apps/cli/vitest.config.ts | 2 +- pnpm-lock.yaml | 16 ++ src/core/webview/webviewMessageHandler.ts | 23 ++ 29 files changed, 1986 insertions(+), 32 deletions(-) create mode 100644 .roo/rules-debug/cli.md create mode 100644 apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts create mode 100644 apps/cli/src/ui/components/Icon.tsx create mode 100644 apps/cli/src/ui/components/TodoChangeDisplay.tsx create mode 100644 apps/cli/src/ui/components/TodoDisplay.tsx create mode 100644 apps/cli/src/ui/components/__tests__/Icon.test.tsx create mode 100644 apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx create mode 100644 apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx create mode 100644 apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx diff --git a/.roo/rules-debug/cli.md b/.roo/rules-debug/cli.md new file mode 100644 index 0000000000..7992718ffa --- /dev/null +++ b/.roo/rules-debug/cli.md @@ -0,0 +1,67 @@ +# CLI Debugging with File-Based Logging + +When debugging the CLI, `console.log` will break the TUI (Terminal User Interface). Use file-based logging to capture debug output without interfering with the application's display. + +## File-Based Logging Strategy + +1. **Write logs to a temporary file instead of console**: + + - Create a log file at a known location, e.g., `/tmp/roo-cli-debug.log` + - Use `fs.appendFileSync()` to write timestamped log entries + - Example logging utility: + + ```typescript + import fs from "fs" + const DEBUG_LOG = "/tmp/roo-cli-debug.log" + + function debugLog(message: string, data?: unknown) { + const timestamp = new Date().toISOString() + const entry = data + ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n` + : `[${timestamp}] ${message}\n` + fs.appendFileSync(DEBUG_LOG, entry) + } + ``` + +2. **Clear the log file before each debugging session**: + - Run `echo "" > /tmp/roo-cli-debug.log` or use `fs.writeFileSync(DEBUG_LOG, "")` at app startup during debugging + +## Iterative Debugging Workflow + +Follow this feedback loop to systematically narrow down issues: + +1. **Add targeted logging** at suspected problem areas based on your hypotheses +2. **Instruct the user** to reproduce the issue using the CLI normally +3. **Read the log file** after the user completes testing: + - Run `cat /tmp/roo-cli-debug.log` to retrieve the captured output +4. **Analyze the log output** to gather clues about: + - Execution flow and timing + - Variable values at key points + - Which code paths were taken + - Error conditions or unexpected states +5. **Refine your logging** based on findingsβ€”add more detail where needed, remove noise +6. **Ask the user to test again** with updated logging +7. **Repeat** until the root cause is identified + +## Best Practices + +- Log entry/exit points of functions under investigation +- Include relevant variable values and state information +- Use descriptive prefixes to categorize logs: `[STATE]`, `[EVENT]`, `[ERROR]`, `[FLOW]` +- Log both the "happy path" and error handling branches +- When dealing with async operations, log before and after `await` statements +- For user interactions, log the received input and the resulting action + +## Example Debug Session + +```typescript +// Add logging to investigate a picker selection issue +debugLog("[FLOW] PickerSelect onSelect called", { selectedIndex, item }) +debugLog("[STATE] Current selection state", { currentValue, isOpen }) + +// After async operation +const result = await fetchOptions() +debugLog("[FLOW] fetchOptions completed", { resultCount: result.length }) +``` + +Then ask: "Please reproduce the issue by [specific steps]. When you're done, let me know and I'll analyze the debug logs." diff --git a/apps/cli/package.json b/apps/cli/package.json index f580abfca6..3338e6337e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -35,6 +35,7 @@ "@roo-code/config-typescript": "workspace:^", "@types/node": "^24.1.0", "@types/react": "^19.1.6", + "ink-testing-library": "^4.0.0", "rimraf": "^6.0.1", "tsup": "^8.4.0", "vitest": "^3.2.3" diff --git a/apps/cli/src/__tests__/ScrollArea.test.ts b/apps/cli/src/__tests__/ScrollArea.test.ts index ff54edd5de..61a820841c 100644 --- a/apps/cli/src/__tests__/ScrollArea.test.ts +++ b/apps/cli/src/__tests__/ScrollArea.test.ts @@ -161,6 +161,19 @@ describe("ScrollArea reducer", () => { expect(newState.scrollTop).toBe(3) // Unchanged }) + it("should NOT auto-scroll when content grows if autoScroll is disabled (picker use case)", () => { + const state: ScrollAreaState = { + ...initialState, + innerHeight: 5, + scrollTop: 0, + autoScroll: false, + } + const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 20 }) + expect(newState.innerHeight).toBe(20) + // scrollTop should remain at 0, not jump to bottom + expect(newState.scrollTop).toBe(0) + }) + it("should clamp scrollTop when content shrinks", () => { const state: ScrollAreaState = { ...initialState, diff --git a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts index 1fab6147d7..70e97cdd5b 100644 --- a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts +++ b/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts @@ -95,7 +95,7 @@ describe("FileTrigger", () => { }) describe("search", () => { - it("should call onSearch and return current results", () => { + it("should call onSearch and return empty array immediately (async pattern)", () => { const onSearch = vi.fn() const mockResults: FileResult[] = [{ key: "test.ts", path: "test.ts", type: "file" }] const getResults = vi.fn(() => mockResults) @@ -103,9 +103,92 @@ describe("FileTrigger", () => { const result = trigger.search("test") + // search() should trigger the API call expect(onSearch).toHaveBeenCalledWith("test") + // search() should return empty immediately for async sources + // (actual results come via refreshResults when API responds) + expect(result).toEqual([]) + // getResults should NOT be called by search() - that's the async fix + expect(getResults).not.toHaveBeenCalled() + }) + + it("should return empty array when no results", () => { + const onSearch = vi.fn() + const getResults = vi.fn(() => []) + const trigger = createFileTrigger({ onSearch, getResults }) + + const result = trigger.search("test") + + expect(result).toEqual([]) + }) + }) + + describe("refreshResults", () => { + it("should call getResults 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.refreshResults!("test") + + // refreshResults should call getResults (not onSearch) expect(getResults).toHaveBeenCalled() - expect(result).toBe(mockResults) + expect(onSearch).not.toHaveBeenCalled() + expect(result).toEqual(mockResults) + }) + + it("should sort results by fuzzy match score (best matches first)", () => { + const onSearch = vi.fn() + const mockResults: FileResult[] = [ + { key: "src/components/Button.tsx", path: "src/components/Button.tsx", type: "file" }, + { key: "app.ts", path: "app.ts", type: "file" }, + { key: "src/app.tsx", path: "src/app.tsx", type: "file" }, + { key: "tests/app.test.ts", path: "tests/app.test.ts", type: "file" }, + ] + const getResults = vi.fn(() => mockResults) + const trigger = createFileTrigger({ onSearch, getResults }) + + const result = trigger.refreshResults!("app") as FileResult[] + + // Results should be sorted with best matches first + // "app.ts" should rank higher than "src/app.tsx" or "tests/app.test.ts" + expect(result[0]?.path).toBe("app.ts") + }) + + it("should filter out results that don't match well", () => { + const onSearch = vi.fn() + const mockResults: FileResult[] = [ + { key: "src/test.ts", path: "src/test.ts", type: "file" }, + { key: "config.json", path: "config.json", type: "file" }, + ] + const getResults = vi.fn(() => mockResults) + const trigger = createFileTrigger({ onSearch, getResults }) + + const result = trigger.refreshResults!("xyz") as FileResult[] + + // Results that don't match well are filtered out by fuzzysort + expect(result.length).toBeLessThan(mockResults.length) + }) + + it("should return results sorted with partial matches", () => { + const onSearch = vi.fn() + const mockResults: FileResult[] = [ + { key: "src/test.ts", path: "src/test.ts", type: "file" }, + { key: "tests/unit.ts", path: "tests/unit.ts", type: "file" }, + { key: "package.json", path: "package.json", type: "file" }, + ] + const getResults = vi.fn(() => mockResults) + const trigger = createFileTrigger({ onSearch, getResults }) + + const result = trigger.refreshResults!("test") as FileResult[] + + // Should return files that match "test" + expect(result.length).toBeGreaterThan(0) + // All returned results should contain "test" in their path + result.forEach((r: FileResult) => { + expect(r.path.toLowerCase()).toContain("test") + }) }) }) }) diff --git a/apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts b/apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts new file mode 100644 index 0000000000..ca928c26b6 --- /dev/null +++ b/apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest" +import { + createModeTrigger, + toModeResult, + type ModeResult, +} from "../../ui/components/autocomplete/triggers/ModeTrigger.js" + +describe("ModeTrigger", () => { + const testModes: ModeResult[] = [ + { key: "code", slug: "code", name: "Code", description: "Write and modify code" }, + { key: "architect", slug: "architect", name: "Architect", description: "Plan and design" }, + { key: "debug", slug: "debug", name: "Debug", description: "Troubleshoot issues" }, + { key: "ask", slug: "ask", name: "Ask", description: "Get explanations" }, + ] + + describe("createModeTrigger", () => { + it("should create a trigger with correct configuration", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + expect(trigger.id).toBe("mode") + expect(trigger.triggerChar).toBe("!") + expect(trigger.position).toBe("line-start") + expect(trigger.emptyMessage).toBe("No matching modes found") + expect(trigger.debounceMs).toBe(150) + }) + + it("should detect trigger at line start", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const result = trigger.detectTrigger("!code") + + expect(result).not.toBeNull() + expect(result?.query).toBe("code") + expect(result?.triggerIndex).toBe(0) + }) + + it("should detect trigger after whitespace", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const result = trigger.detectTrigger(" !architect") + + expect(result).not.toBeNull() + expect(result?.query).toBe("architect") + expect(result?.triggerIndex).toBe(2) + }) + + it("should not detect trigger in middle of text", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const result = trigger.detectTrigger("some text !code") + + expect(result).toBeNull() + }) + + it("should close picker when query contains space", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const result = trigger.detectTrigger("!code something") + + expect(result).toBeNull() + }) + + it("should return all modes when query is empty", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const results = trigger.search("") + + expect(results).toEqual(testModes) + }) + + it("should filter modes by name using fuzzy search", async () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const results = await trigger.search("deb") + + expect(results).toHaveLength(1) + expect(results[0]!.slug).toBe("debug") + }) + + it("should filter modes by slug using fuzzy search", async () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const results = await trigger.search("arch") + + expect(results).toHaveLength(1) + expect(results[0]!.slug).toBe("architect") + }) + + it("should respect maxResults limit", async () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + maxResults: 2, + }) + + const results = await trigger.search("") + + expect(results.length).toBeLessThanOrEqual(2) + }) + + it("should return empty replacement text", () => { + const trigger = createModeTrigger({ + getModes: () => testModes, + }) + + const mode = testModes[0]! + const replacement = trigger.getReplacementText(mode, "!code", 0) + + expect(replacement).toBe("") + }) + }) + + describe("toModeResult", () => { + it("should convert mode data to ModeResult", () => { + const modeData = { + slug: "code", + name: "Code", + description: "Write and modify code", + icon: "πŸ’»", + } + + const result = toModeResult(modeData) + + expect(result).toEqual({ + key: "code", + slug: "code", + name: "Code", + description: "Write and modify code", + icon: "πŸ’»", + }) + }) + + it("should handle mode without description", () => { + const modeData = { + slug: "test", + name: "Test Mode", + } + + const result = toModeResult(modeData) + + expect(result).toEqual({ + key: "test", + slug: "test", + name: "Test Mode", + description: undefined, + icon: undefined, + }) + }) + }) +}) diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts index aeaa8a65d9..b2259c614e 100644 --- a/apps/cli/src/extension-host.ts +++ b/apps/cli/src/extension-host.ts @@ -633,6 +633,11 @@ export class ExtensionHost extends EventEmitter { this.handleInvokeMessage(msg) break + case "modes": + // Forward modes list to the TUI + this.emit("extensionWebviewMessage", msg) + break + default: // Log unknown message types in verbose mode if (this.options.verbose) { diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 1bec82ddc0..e3be3f432f 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -6,7 +6,7 @@ import { randomUUID } from "crypto" // Import only message-utils to avoid custom-tools dependencies (execa/child_process) import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" -import type { ClineMessage } from "@roo-code/types" +import type { ClineMessage, TodoItem } from "@roo-code/types" import { useCLIStore } from "./store.js" import { getContextWindow } from "../utils/getContextWindow.js" @@ -18,13 +18,16 @@ import { PickerSelect, createFileTrigger, createSlashCommandTrigger, + createModeTrigger, toFileResult, toSlashCommandResult, + toModeResult, type AutocompleteInputHandle, type AutocompletePickerState, type AutocompleteTrigger, type FileResult, type SlashCommandResult as SlashCommandItem, + type ModeResult as ModeItem, } from "./components/autocomplete/index.js" import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" import ScrollIndicator from "./components/ScrollIndicator.js" @@ -39,6 +42,7 @@ import type { View, FileSearchResult, SlashCommandResult, + ModeResult, } from "./types.js" // Layout constants @@ -169,14 +173,20 @@ function AppInner({ setError, fileSearchResults, allSlashCommands, + availableModes, setFileSearchResults, setAllSlashCommands, + setAvailableModes, + currentMode, + setCurrentMode, tokenUsage, routerModels, apiConfiguration, setTokenUsage, setRouterModels, setApiConfiguration, + currentTodos, + setTodos, } = useCLIStore() // Compute context window from router models and API configuration @@ -279,9 +289,12 @@ function AppInner({ } }, []) - // File search handler for the file trigger + // File search handler for the file trigger. const handleFileSearch = useCallback((query: string) => { - if (!hostRef.current) return + if (!hostRef.current) { + return + } + hostRef.current.sendToExtension({ type: "searchFiles", query, @@ -289,7 +302,7 @@ function AppInner({ }, []) // Create autocomplete triggers - // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult) + // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult) // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteTriggers = useMemo((): AutocompleteTrigger[] => { const fileTrigger = createFileTrigger({ @@ -301,8 +314,12 @@ function AppInner({ getCommands: () => allSlashCommands.map(toSlashCommandResult), }) - return [fileTrigger, slashCommandTrigger] - }, [handleFileSearch, fileSearchResults, allSlashCommands]) + const modeTrigger = createModeTrigger({ + getModes: () => availableModes.map(toModeResult), + }) + + return [fileTrigger, slashCommandTrigger, modeTrigger] + }, [handleFileSearch, fileSearchResults, allSlashCommands, availableModes]) // Handle Ctrl+C and Tab for focus switching useInput((input, key) => { @@ -356,6 +373,16 @@ function AppInner({ } }, []) + // FIX: Refresh search results when fileSearchResults changes while file picker is open + // This fixes the async timing issue where getResults() returns empty before API responds + // Only refresh when we actually have results (not on initial empty state) + useEffect(() => { + if (pickerState.isOpen && pickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0) { + autocompleteRef.current?.refreshSearch() + followupAutocompleteRef.current?.refreshSearch() + } + }, [fileSearchResults, pickerState.isOpen, pickerState.activeTrigger?.id]) + // Map extension say messages to TUI messages const handleSayMessage = useCallback( (ts: number, say: SayType, text: string, partial: boolean) => { @@ -400,6 +427,32 @@ function AppInner({ toolName = toolInfo.tool toolDisplayName = toolInfo.tool toolDisplayOutput = formatToolOutput(toolInfo) + + // Special handling for update_todo_list tool + if (toolName === "update_todo_list" || toolName === "updateTodoList") { + const todos = parseTodosFromToolInfo(toolInfo) + if (todos && todos.length > 0) { + // Capture previous todos before updating + const prevTodos = [...currentTodos] + setTodos(todos) + + seenMessageIds.current.add(messageId) + + addMessage({ + id: messageId, + role: "tool", + content: text || "", + toolName, + toolDisplayName, + toolDisplayOutput, + partial, + originalType: say, + todos, + previousTodos: prevTodos, + }) + return + } + } } catch { toolDisplayOutput = text } @@ -420,7 +473,7 @@ function AppInner({ originalType: say, }) }, - [addMessage, verbose], + [addMessage, verbose, currentTodos, setTodos], ) // Handle extension ask messages @@ -528,6 +581,11 @@ function AppInner({ const state = msg.state as Record if (!state) return + // Extract and update current mode from state + const newMode = state.mode as string | undefined + if (newMode) { + setCurrentMode(newMode) + } const clineMessages = state.clineMessages as Array> | undefined if (clineMessages) { for (const clineMsg of clineMessages) { @@ -589,6 +647,19 @@ function AppInner({ source: cmd.source, })) setAllSlashCommands(slashCommands) + } else if (msg.type === "modes") { + const modes = + (msg.modes as Array<{ + slug: string + name: string + description?: string + }>) || [] + const modeResults: ModeResult[] = modes.map((mode) => ({ + slug: mode.slug, + name: mode.name, + description: mode.description, + })) + setAvailableModes(modeResults) } else if (msg.type === "routerModels") { // Handle router models for context window lookup const models = msg.models as Record> | undefined @@ -608,6 +679,8 @@ function AppInner({ handleAskMessage, setFileSearchResults, setAllSlashCommands, + setAvailableModes, + setCurrentMode, setTokenUsage, setRouterModels, setApiConfiguration, @@ -654,6 +727,7 @@ function AppInner({ await host.activate() host.sendToExtension({ type: "requestCommands" }) + host.sendToExtension({ type: "requestModes" }) setLoading(false) @@ -798,11 +872,30 @@ function AppInner({ }, []) // 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 handlePickerSelect = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => { + // Check if this is a mode selection + if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) { + const modeItem = item as ModeItem + // Send mode change message to extension + if (hostRef.current) { + hostRef.current.sendToExtension({ + type: "switchMode", + mode: modeItem.slug, + }) + } + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } else { + // Handle other item selections normally + autocompleteRef.current?.handleItemSelect(item) + followupAutocompleteRef.current?.handleItemSelect(item) + } + }, + [pickerState.activeTrigger], + ) // Handle picker close from external PickerSelect const handlePickerClose = useCallback(() => { @@ -870,7 +963,7 @@ function AppInner({
): string { return `πŸ“‹ Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}` } + case "update_todo_list": + case "updateTodoList": { + // Special marker - actual rendering is handled by TodoChangeDisplay component + return "β˜‘ TODO list updated" + } + default: { const params = Object.entries(toolInfo) .filter(([key]) => key !== "tool") @@ -1167,3 +1266,77 @@ function formatToolAskMessage(toolInfo: Record): string { } } } + +/** + * Parse TODO items from tool info + * Handles both array format and markdown checklist string format + */ +function parseTodosFromToolInfo(toolInfo: Record): TodoItem[] | null { + // Try to get todos directly as an array + const todosArray = toolInfo.todos as unknown[] | undefined + if (Array.isArray(todosArray)) { + return todosArray + .map((item, index) => { + if (typeof item === "object" && item !== null) { + const todo = item as Record + return { + id: (todo.id as string) || `todo-${index}`, + content: (todo.content as string) || "", + status: ((todo.status as string) || "pending") as TodoItem["status"], + } + } + return null + }) + .filter((item): item is TodoItem => item !== null) + } + + // Try to parse markdown checklist format from todos string + const todosString = toolInfo.todos as string | undefined + if (typeof todosString === "string") { + return parseMarkdownChecklist(todosString) + } + + return null +} + +/** + * Parse a markdown checklist string into TodoItem array + * Format: + * [ ] pending item + * [-] in progress item + * [x] completed item + */ +function parseMarkdownChecklist(markdown: string): TodoItem[] { + const lines = markdown.split("\n") + const todos: TodoItem[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line) continue + + const trimmedLine = line.trim() + if (!trimmedLine) continue + + // Match markdown checkbox patterns + const checkboxMatch = trimmedLine.match(/^\[([x\-\s])\]\s*(.+)$/i) + if (checkboxMatch) { + const statusChar = checkboxMatch[1] ?? " " + const content = checkboxMatch[2] ?? "" + let status: TodoItem["status"] = "pending" + + if (statusChar.toLowerCase() === "x") { + status = "completed" + } else if (statusChar === "-") { + status = "in_progress" + } + + todos.push({ + id: `todo-${i}`, + content: content.trim(), + status, + }) + } + } + + return todos +} diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 86fac28ae6..06fc7ce68a 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -3,6 +3,7 @@ import { Box, Newline, Text } from "ink" import * as theme from "../utils/theme.js" import type { TUIMessage } from "../types.js" +import TodoDisplay from "./TodoDisplay.js" interface ChatHistoryItemProps { message: TUIMessage @@ -49,6 +50,22 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { ) case "tool": { + // Special rendering for update_todo_list tool - show full TODO list + if ( + (message.toolName === "update_todo_list" || message.toolName === "updateTodoList") && + message.todos && + message.todos.length > 0 + ) { + return ( + + + + + + + ) + } + let toolContent = message.toolDisplayOutput || content // Replace tab characters with spaces to prevent terminal width miscalculation diff --git a/apps/cli/src/ui/components/Icon.tsx b/apps/cli/src/ui/components/Icon.tsx new file mode 100644 index 0000000000..5a030f543d --- /dev/null +++ b/apps/cli/src/ui/components/Icon.tsx @@ -0,0 +1,144 @@ +import { Box, Text } from "ink" +import type { TextProps } from "ink" + +/** + * Icon names supported by the Icon component. + * Each icon has a Nerd Font glyph and an ASCII fallback. + */ +export type IconName = "folder" | "file" | "check" | "cross" | "arrow-right" | "bullet" | "spinner" + +/** + * Icon definitions with Nerd Font glyph and ASCII fallback. + * Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char). + */ +const ICONS: Record = { + folder: { nerd: "\udb80\ude4b", fallback: "β–Ό" }, + file: { nerd: "\udb80\ude14", fallback: "●" }, + check: { nerd: "\uf00c", fallback: "βœ“" }, + cross: { nerd: "\uf00d", fallback: "βœ—" }, + "arrow-right": { nerd: "\uf061", fallback: "β†’" }, + bullet: { nerd: "\uf111", fallback: "β€’" }, + spinner: { nerd: "\uf110", fallback: "*" }, +} + +/** + * Check if a string contains surrogate pairs (characters outside BMP). + * Surrogate pairs have .length of 2 but render as 1 visual character. + */ +function containsSurrogatePair(str: string): boolean { + // Surrogate pairs are in the range U+D800 to U+DFFF + return /[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(str) +} + +/** + * Detect if Nerd Font icons are likely supported. + * + * Users can override this with the ROOCODE_NERD_FONT environment variable: + * - ROOCODE_NERD_FONT=0 to force ASCII fallbacks (if icons don't render correctly) + * - ROOCODE_NERD_FONT=1 to force Nerd Font icons + * + * Defaults to true because: + * 1. Nerd Fonts are common in developer terminal setups + * 2. Modern terminals handle missing glyphs gracefully + * 3. Users can easily disable if icons don't render correctly + */ +function detectNerdFontSupport(): boolean { + // Allow explicit override via environment variable + const envOverride = process.env.ROOCODE_NERD_FONT + if (envOverride === "0" || envOverride === "false") return false + if (envOverride === "1" || envOverride === "true") return true + + // Default to Nerd Font icons - they're common in developer setups + // and users can set ROOCODE_NERD_FONT=0 if needed + return true +} + +// Cache the detection result +let nerdFontSupported: boolean | null = null + +/** + * Get whether Nerd Font icons are supported (cached). + */ +export function isNerdFontSupported(): boolean { + if (nerdFontSupported === null) { + nerdFontSupported = detectNerdFontSupport() + } + return nerdFontSupported +} + +/** + * Reset the Nerd Font detection cache (useful for testing). + */ +export function resetNerdFontCache(): void { + nerdFontSupported = null +} + +export interface IconProps extends Omit { + /** The icon to display */ + name: IconName + /** Override the automatic Nerd Font detection */ + useNerdFont?: boolean + /** Custom width for the icon container (default: 2) */ + width?: number +} + +/** + * Icon component that renders Nerd Font icons with ASCII fallbacks. + * + * Renders icons in a fixed-width Box to handle surrogate pair width + * calculation issues in Ink. Surrogate pairs (like Nerd Font glyphs) + * have .length of 2 in JavaScript but render as 1 visual character. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: IconProps) { + const iconDef = ICONS[name] + if (!iconDef) { + return null + } + + const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported() + const icon = shouldUseNerdFont ? iconDef.nerd : iconDef.fallback + + // DEBUG: Log icon selection + console.error( + `DEBUG Icon: name=${name}, shouldUseNerdFont=${shouldUseNerdFont}, envOverride=${process.env.ROOCODE_NERD_FONT}, icon.length=${icon.length}`, + ) + + // Use fixed-width Box to isolate surrogate pair width calculation + // from surrounding text. This prevents the off-by-one truncation bug. + const needsWidthFix = containsSurrogatePair(icon) + + if (needsWidthFix) { + return ( + + + {icon} + + + ) + } + + // For BMP characters (no surrogate pairs), render directly + return ( + + {icon} + + ) +} + +/** + * Get the raw icon character (useful for string concatenation). + */ +export function getIconChar(name: IconName, useNerdFont?: boolean): string { + const iconDef = ICONS[name] + if (!iconDef) return "" + + const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported() + return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback +} diff --git a/apps/cli/src/ui/components/ScrollArea.tsx b/apps/cli/src/ui/components/ScrollArea.tsx index bc0a552a1b..9bad5ee92d 100644 --- a/apps/cli/src/ui/components/ScrollArea.tsx +++ b/apps/cli/src/ui/components/ScrollArea.tsx @@ -161,6 +161,8 @@ export interface ScrollAreaProps { scrollToLine?: number scrollToLineTrigger?: number showScrollbar?: boolean + /** Whether to auto-scroll to bottom when content grows. Default: true */ + autoScroll?: boolean } export function ScrollArea({ @@ -173,6 +175,7 @@ export function ScrollArea({ scrollToLine, scrollToLineTrigger, showScrollbar = true, + autoScroll: autoScrollProp = true, }: ScrollAreaProps) { // Ref for measuring outer container height when not provided const outerRef = useRef(null) @@ -185,7 +188,7 @@ export function ScrollArea({ height: height, scrollTop: 0, innerHeight: 0, - autoScroll: true, + autoScroll: autoScrollProp, }) const innerRef = useRef(null) diff --git a/apps/cli/src/ui/components/TodoChangeDisplay.tsx b/apps/cli/src/ui/components/TodoChangeDisplay.tsx new file mode 100644 index 0000000000..39d9d03f4b --- /dev/null +++ b/apps/cli/src/ui/components/TodoChangeDisplay.tsx @@ -0,0 +1,142 @@ +import { memo } from "react" +import { Box, Text } from "ink" + +import type { TodoItem } from "@roo-code/types" + +import * as theme from "../utils/theme.js" + +/** + * Status icons for TODO items using Unicode characters + */ +const STATUS_ICONS = { + completed: "βœ“", + in_progress: "β†’", + pending: "β—‹", +} as const + +/** + * Get the color for a TODO status + */ +function getStatusColor(status: TodoItem["status"]): string { + switch (status) { + case "completed": + return theme.successColor + case "in_progress": + return theme.warningColor + case "pending": + default: + return theme.dimText + } +} + +interface TodoChangeDisplayProps { + /** Previous TODO list for comparison */ + previousTodos: TodoItem[] + /** New TODO list */ + newTodos: TodoItem[] +} + +/** + * TodoChangeDisplay component for CLI + * + * Shows only the items that changed between two TODO lists. + * Used for compact inline display in the chat history. + * + * Visual example: + * ``` + * β˜‘ TODO Updated + * βœ“ Design architecture [completed] + * β†’ Implement core logic [started] + * ``` + */ +function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) { + if (!newTodos || newTodos.length === 0) { + return null + } + + const isInitialState = previousTodos.length === 0 + + // Determine which todos to display + let todosToDisplay: TodoItem[] + + if (isInitialState) { + // For initial state, show all todos + todosToDisplay = newTodos + } else { + // For updates, only show changes (completed or started items) + todosToDisplay = newTodos.filter((newTodo) => { + if (newTodo.status === "completed") { + const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) + return !previousTodo || previousTodo.status !== "completed" + } + if (newTodo.status === "in_progress") { + const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content) + return !previousTodo || previousTodo.status !== "in_progress" + } + return false + }) + } + + // If no changes to display, show nothing + if (todosToDisplay.length === 0) { + return null + } + + // Calculate progress for summary + const totalCount = newTodos.length + const completedCount = newTodos.filter((t) => t.status === "completed").length + + return ( + + {/* Header with progress summary */} + + + β˜‘ TODO {isInitialState ? "List" : "Updated"} + + + {" "} + ({completedCount}/{totalCount}) + + + + {/* Changed items */} + + {todosToDisplay.map((todo, index) => { + const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.pending + const color = getStatusColor(todo.status) + + // Determine what changed + const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content) + let changeLabel: string | null = null + + if (isInitialState) { + // Don't show labels for initial state + changeLabel = null + } else if (!previousTodo) { + changeLabel = "new" + } else if (todo.status === "completed" && previousTodo.status !== "completed") { + changeLabel = "done" + } else if (todo.status === "in_progress" && previousTodo.status !== "in_progress") { + changeLabel = "started" + } + + return ( + + + {icon} {todo.content} + + {changeLabel && ( + + {" "} + [{changeLabel}] + + )} + + ) + })} + + + ) +} + +export default memo(TodoChangeDisplay) diff --git a/apps/cli/src/ui/components/TodoDisplay.tsx b/apps/cli/src/ui/components/TodoDisplay.tsx new file mode 100644 index 0000000000..6c8567d308 --- /dev/null +++ b/apps/cli/src/ui/components/TodoDisplay.tsx @@ -0,0 +1,179 @@ +import { memo } from "react" +import { Box, Text } from "ink" + +import type { TodoItem } from "@roo-code/types" + +import * as theme from "../utils/theme.js" +import ProgressBar from "./ProgressBar.js" + +/** + * Status icons for TODO items using Unicode characters + */ +const STATUS_ICONS = { + completed: "βœ“", + in_progress: "β†’", + pending: "β—‹", +} as const + +/** + * Get the color for a TODO status + */ +function getStatusColor(status: TodoItem["status"]): string { + switch (status) { + case "completed": + return theme.successColor + case "in_progress": + return theme.warningColor + case "pending": + default: + return theme.dimText + } +} + +interface TodoDisplayProps { + /** List of TODO items to display */ + todos: TodoItem[] + /** Previous TODO list for diff comparison (optional) */ + previousTodos?: TodoItem[] + /** Whether to show the progress bar (default: true) */ + showProgress?: boolean + /** Whether to show only changed items (default: false) */ + showChangesOnly?: boolean + /** Title to display in the header (default: "TODO List Updated") */ + title?: string +} + +/** + * TodoDisplay component for CLI + * + * Renders a beautiful TODO list visualization with: + * - Status icons (βœ“ completed, β†’ in progress, β—‹ pending) + * - Color-coded items based on status + * - Progress bar showing completion percentage + * - Optional diff mode showing only changed items + * + * Visual example: + * ``` + * β”Œβ”€ TODO List Updated ──────────────────────────────┐ + * β”‚ βœ“ Analyze requirements β”‚ + * β”‚ βœ“ Design architecture β”‚ + * β”‚ β†’ Implement core logic β”‚ + * β”‚ β—‹ Write tests β”‚ + * β”‚ β—‹ Update documentation β”‚ + * β”‚ [β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘] 2/5 completed β”‚ + * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + * ``` + */ +function TodoDisplay({ + todos, + previousTodos = [], + showProgress = true, + showChangesOnly = false, + title = "TODO List Updated", +}: TodoDisplayProps) { + if (!todos || todos.length === 0) { + return null + } + + // Determine which todos to display + let displayTodos: TodoItem[] + + if (showChangesOnly && previousTodos.length > 0) { + // Filter to only show items that changed status + displayTodos = todos.filter((todo) => { + const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content) + if (!previousTodo) { + // New item + return true + } + // Status changed + return previousTodo.status !== todo.status + }) + } else { + displayTodos = todos + } + + // If filtering and nothing changed, don't render + if (showChangesOnly && displayTodos.length === 0) { + return null + } + + // Calculate progress statistics + const totalCount = todos.length + const completedCount = todos.filter((t) => t.status === "completed").length + const inProgressCount = todos.filter((t) => t.status === "in_progress").length + + return ( + + {/* Header */} + + + β˜‘ {title} + + + + {/* Border top */} + + {"─".repeat(50)} + + + {/* TODO items */} + + {displayTodos.map((todo, index) => { + const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.pending + const color = getStatusColor(todo.status) + + // Check if this item changed status + const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content) + const statusChanged = previousTodo && previousTodo.status !== todo.status + const isNew = previousTodos.length > 0 && !previousTodo + + return ( + + + {icon} {todo.content} + + {statusChanged && ( + + {" "} + [ + {todo.status === "completed" + ? "done" + : todo.status === "in_progress" + ? "started" + : "reset"} + ] + + )} + {isNew && ( + + {" "} + [new] + + )} + + ) + })} + + + {/* Progress bar and stats */} + {showProgress && ( + + + {"─".repeat(50)} + + + + + {" "} + {completedCount}/{totalCount} completed + {inProgressCount > 0 && `, ${inProgressCount} in progress`} + + + + )} + + ) +} + +export default memo(TodoDisplay) diff --git a/apps/cli/src/ui/components/__tests__/Icon.test.tsx b/apps/cli/src/ui/components/__tests__/Icon.test.tsx new file mode 100644 index 0000000000..318f83bb71 --- /dev/null +++ b/apps/cli/src/ui/components/__tests__/Icon.test.tsx @@ -0,0 +1,162 @@ +import { render } from "ink-testing-library" + +import { Icon, isNerdFontSupported, resetNerdFontCache, getIconChar } from "../Icon.js" + +describe("Icon", () => { + beforeEach(() => { + // Reset cache before each test + resetNerdFontCache() + // Clear environment variables + delete process.env.ROOCODE_NERD_FONT + }) + + afterEach(() => { + resetNerdFontCache() + delete process.env.ROOCODE_NERD_FONT + }) + + describe("rendering", () => { + it("should render folder icon", () => { + const { lastFrame } = render() + // Should render something (either nerd font or fallback) + expect(lastFrame()).toBeDefined() + }) + + it("should render file icon", () => { + const { lastFrame } = render() + expect(lastFrame()).toBeDefined() + }) + + it("should render check icon", () => { + const { lastFrame } = render() + expect(lastFrame()).toBeDefined() + }) + + it("should render cross icon", () => { + const { lastFrame } = render() + expect(lastFrame()).toBeDefined() + }) + + it("should apply color prop", () => { + const { lastFrame } = render() + expect(lastFrame()).toBeDefined() + }) + + it("should return null for unknown icon name", () => { + // @ts-expect-error - testing invalid icon name + const { lastFrame } = render() + expect(lastFrame()).toBe("") + }) + }) + + describe("Nerd Font detection", () => { + it("should respect ROOCODE_NERD_FONT=1 environment variable", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(true) + }) + + it("should respect ROOCODE_NERD_FONT=true environment variable", () => { + process.env.ROOCODE_NERD_FONT = "true" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(true) + }) + + it("should respect ROOCODE_NERD_FONT=0 environment variable", () => { + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(false) + }) + + it("should respect ROOCODE_NERD_FONT=false environment variable", () => { + process.env.ROOCODE_NERD_FONT = "false" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(false) + }) + + it("should cache detection result", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + const first = isNerdFontSupported() + // Change env var - should still use cached value + process.env.ROOCODE_NERD_FONT = "0" + const second = isNerdFontSupported() + expect(first).toBe(true) + expect(second).toBe(true) // Still true because cached + }) + + it("should reset cache when resetNerdFontCache is called", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(true) + + // Reset and change + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + expect(isNerdFontSupported()).toBe(false) + }) + }) + + describe("useNerdFont prop override", () => { + it("should force Nerd Font when useNerdFont=true", () => { + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + + const { lastFrame } = render() + // The nerd font icon is a surrogate pair + const frame = lastFrame() || "" + // Surrogate pair should be present (even if it renders oddly in tests) + expect(frame.length).toBeGreaterThan(0) + }) + + it("should force fallback when useNerdFont=false", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + + const { lastFrame } = render() + const frame = lastFrame() || "" + // Fallback for folder is "β–Ό" (single char) + expect(frame).toContain("β–Ό") + }) + }) + + describe("getIconChar", () => { + it("should return fallback character when Nerd Font disabled", () => { + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + + expect(getIconChar("folder")).toBe("β–Ό") + expect(getIconChar("file")).toBe("●") + expect(getIconChar("check")).toBe("βœ“") + expect(getIconChar("cross")).toBe("βœ—") + }) + + it("should return Nerd Font character when enabled", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + + // Nerd Font icons are surrogate pairs (length 2) + expect(getIconChar("folder").length).toBe(2) + expect(getIconChar("file").length).toBe(2) + }) + + it("should respect useNerdFont override", () => { + process.env.ROOCODE_NERD_FONT = "1" + resetNerdFontCache() + + // Force fallback + expect(getIconChar("folder", false)).toBe("β–Ό") + + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + + // Force Nerd Font + expect(getIconChar("folder", true).length).toBe(2) + }) + + it("should return empty string for unknown icon", () => { + // @ts-expect-error - testing invalid icon name + expect(getIconChar("unknown")).toBe("") + }) + }) +}) diff --git a/apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx b/apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx new file mode 100644 index 0000000000..a26c66e3df --- /dev/null +++ b/apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx @@ -0,0 +1,149 @@ +import { render } from "ink-testing-library" + +import type { TodoItem } from "@roo-code/types" + +import TodoChangeDisplay from "../TodoChangeDisplay.js" + +describe("TodoChangeDisplay", () => { + it("renders all todos for initial state (no previous todos)", () => { + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "Task 2", status: "in_progress" }, + { id: "3", content: "Task 3", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + // Check header shows "List" for initial state + expect(output).toContain("TODO List") + + // All items should be shown + expect(output).toContain("Task 1") + expect(output).toContain("Task 2") + expect(output).toContain("Task 3") + + // Progress should be shown + expect(output).toContain("(1/3)") + }) + + it("shows only changed items when previous todos exist", () => { + const previousTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "pending" }, + { id: "2", content: "Task 2", status: "pending" }, + { id: "3", content: "Task 3", status: "pending" }, + ] + + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, // Changed to completed + { id: "2", content: "Task 2", status: "in_progress" }, // Changed to in_progress + { id: "3", content: "Task 3", status: "pending" }, // No change + ] + + const { lastFrame } = render() + const output = lastFrame() + + // Header should say "Updated" + expect(output).toContain("TODO Updated") + + // Only changed items should be shown + expect(output).toContain("Task 1") + expect(output).toContain("Task 2") + + // Unchanged item should NOT be shown + // Note: We can check if "Task 3" appears but since rendering is compact, + // we'll check for change labels instead + expect(output).toContain("[done]") + expect(output).toContain("[started]") + }) + + it("returns null when no todos provided", () => { + const { lastFrame } = render() + expect(lastFrame()).toBe("") + }) + + it("returns null when no changes detected", () => { + const todos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "Task 2", status: "pending" }, + ] + + const { lastFrame } = render() + // No changes means nothing to display + expect(lastFrame()).toBe("") + }) + + it("shows [new] label for newly added items", () => { + const previousTodos: TodoItem[] = [{ id: "1", content: "Task 1", status: "completed" }] + + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "New Task", status: "in_progress" }, // New item + ] + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("New Task") + expect(output).toContain("[new]") + }) + + it("displays correct status icons", () => { + const newTodos: TodoItem[] = [ + { id: "1", content: "Completed task", status: "completed" }, + { id: "2", content: "In progress task", status: "in_progress" }, + { id: "3", content: "Pending task", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + // Check status icons + expect(output).toContain("βœ“") // completed + expect(output).toContain("β†’") // in_progress + expect(output).toContain("β—‹") // pending + }) + + it("shows progress summary in header", () => { + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "Task 2", status: "completed" }, + { id: "3", content: "Task 3", status: "pending" }, + { id: "4", content: "Task 4", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + // 2 out of 4 completed + expect(output).toContain("(2/4)") + }) + + it("does not show labels for initial state items", () => { + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "in_progress" }, + { id: "2", content: "Task 2", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + // Initial state should not have change labels like [done], [started], [new] + expect(output).not.toContain("[done]") + expect(output).not.toContain("[started]") + expect(output).not.toContain("[new]") + }) + + it("handles matching by content when ids differ", () => { + const previousTodos: TodoItem[] = [{ id: "old-1", content: "Same content task", status: "pending" }] + + const newTodos: TodoItem[] = [{ id: "new-1", content: "Same content task", status: "completed" }] + + const { lastFrame } = render() + const output = lastFrame() + + // Should recognize as the same task that changed status + expect(output).toContain("Same content task") + expect(output).toContain("[done]") + }) +}) diff --git a/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx b/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx new file mode 100644 index 0000000000..e877fcba72 --- /dev/null +++ b/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx @@ -0,0 +1,138 @@ +import { render } from "ink-testing-library" + +import type { TodoItem } from "@roo-code/types" + +import TodoDisplay from "../TodoDisplay.js" + +describe("TodoDisplay", () => { + const mockTodos: TodoItem[] = [ + { id: "1", content: "Analyze requirements", status: "completed" }, + { id: "2", content: "Design architecture", status: "completed" }, + { id: "3", content: "Implement core logic", status: "in_progress" }, + { id: "4", content: "Write tests", status: "pending" }, + { id: "5", content: "Update documentation", status: "pending" }, + ] + + it("renders all todos with correct status icons", () => { + const { lastFrame } = render() + const output = lastFrame() + + // Check header + expect(output).toContain("TODO List Updated") + + // Check all items are rendered + expect(output).toContain("Analyze requirements") + expect(output).toContain("Design architecture") + expect(output).toContain("Implement core logic") + expect(output).toContain("Write tests") + expect(output).toContain("Update documentation") + + // Check status icons are present + expect(output).toContain("βœ“") // completed + expect(output).toContain("β†’") // in_progress + expect(output).toContain("β—‹") // pending + }) + + it("renders progress bar when showProgress is true", () => { + const { lastFrame } = render() + const output = lastFrame() + + // Check progress stats + expect(output).toContain("2/5 completed") + }) + + it("hides progress bar when showProgress is false", () => { + const { lastFrame } = render() + const output = lastFrame() + + // Should not show completion stats + expect(output).not.toContain("2/5 completed") + }) + + it("returns null for empty todos array", () => { + const { lastFrame } = render() + expect(lastFrame()).toBe("") + }) + + it("shows only changed items when showChangesOnly is true", () => { + const previousTodos: TodoItem[] = [ + { id: "1", content: "Analyze requirements", status: "completed" }, + { id: "2", content: "Design architecture", status: "in_progress" }, + { id: "3", content: "Implement core logic", status: "pending" }, + ] + + const newTodos: TodoItem[] = [ + { id: "1", content: "Analyze requirements", status: "completed" }, + { id: "2", content: "Design architecture", status: "completed" }, // Changed + { id: "3", content: "Implement core logic", status: "in_progress" }, // Changed + ] + + const { lastFrame } = render( + , + ) + const output = lastFrame() + + // Should show changed items + expect(output).toContain("Design architecture") + expect(output).toContain("Implement core logic") + + // Unchanged item should still be there since we're just filtering by change + // The filter only removes items that haven't changed status + }) + + it("shows change labels for items that changed status", () => { + const previousTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "pending" }, + { id: "2", content: "Task 2", status: "in_progress" }, + ] + + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "in_progress" }, + { id: "2", content: "Task 2", status: "completed" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + // Check change indicators + expect(output).toContain("[started]") + expect(output).toContain("[done]") + }) + + it("shows [new] label for new items", () => { + const previousTodos: TodoItem[] = [{ id: "1", content: "Task 1", status: "completed" }] + + const newTodos: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "New Task", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("New Task") + expect(output).toContain("[new]") + }) + + it("uses custom title when provided", () => { + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("My Custom Title") + }) + + it("calculates in_progress count correctly", () => { + const todosWithMultipleInProgress: TodoItem[] = [ + { id: "1", content: "Task 1", status: "completed" }, + { id: "2", content: "Task 2", status: "in_progress" }, + { id: "3", content: "Task 3", status: "in_progress" }, + { id: "4", content: "Task 4", status: "pending" }, + ] + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("1/4 completed") + expect(output).toContain("2 in progress") + }) +}) diff --git a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx index 5fd9fd0f83..2183c9d9a7 100644 --- a/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx +++ b/apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx @@ -38,6 +38,8 @@ export interface AutocompleteInputHandle void /** Close the picker */ closePicker: () => void + /** Force refresh search results (used when async data arrives after initial search) */ + refreshSearch: () => void } /** @@ -114,11 +116,15 @@ function AutocompleteInputInner( const lastLine = getLastLine(value) pickerActions.handleInputChange(value, lastLine) - if (!isBrowsing) { + // If user types while browsing history, exit browsing mode + // This prevents the history effect from overwriting their edits + if (isBrowsing) { + resetBrowsing(value) + } else { setDraft(value) } }, - [pickerActions, isBrowsing, setDraft, getLastLine], + [pickerActions, isBrowsing, setDraft, getLastLine, resetBrowsing], ) /** @@ -209,8 +215,15 @@ function AutocompleteInputInner( handleItemSelect, handleIndexChange: pickerActions.handleIndexChange, closePicker: pickerActions.handleClose, + refreshSearch: pickerActions.forceRefresh, }), - [pickerState, handleItemSelect, pickerActions.handleIndexChange, pickerActions.handleClose], + [ + pickerState, + handleItemSelect, + pickerActions.handleIndexChange, + pickerActions.handleClose, + pickerActions.forceRefresh, + ], ) return ( diff --git a/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx index ba23e0a061..bc826584ba 100644 --- a/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx +++ b/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx @@ -103,7 +103,8 @@ export function PickerSelect({ isActive={false} showScrollbar={true} scrollToLine={selectedIndex} - scrollToLineTrigger={scrollTrigger}> + scrollToLineTrigger={scrollTrigger} + autoScroll={false}> {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 index 1148323936..e4ce4c4830 100644 --- a/apps/cli/src/ui/components/autocomplete/index.ts +++ b/apps/cli/src/ui/components/autocomplete/index.ts @@ -53,4 +53,8 @@ export { toSlashCommandResult, type SlashCommandResult, type SlashCommandTriggerConfig, + createModeTrigger, + toModeResult, + type ModeResult, + type ModeTriggerConfig, } from "./triggers/index.js" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx new file mode 100644 index 0000000000..3bc8a00dd1 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx @@ -0,0 +1,168 @@ +import { render } from "ink-testing-library" +import { describe, it, expect } from "vitest" + +import { createFileTrigger, toFileResult } from "./FileTrigger.js" + +describe("FileTrigger", () => { + describe("createFileTrigger", () => { + it("should detect @ trigger", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const result = trigger.detectTrigger("@fil") + expect(result).toEqual({ query: "fil", triggerIndex: 0 }) + }) + + it("should detect @ trigger in middle of line", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const result = trigger.detectTrigger("some text @fil") + expect(result).toEqual({ query: "fil", triggerIndex: 10 }) + }) + + it("should not detect @ followed by space", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const result = trigger.detectTrigger("@ ") + expect(result).toBeNull() + }) + + it("should close picker when query contains space", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const result = trigger.detectTrigger("@file name") + expect(result).toBeNull() + }) + + it("should generate correct replacement text for files", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ path: "src/index.ts", type: "file" }) + const lineText = "Check @ind" + const replacement = trigger.getReplacementText(item, lineText, 6) + + expect(replacement).toBe("Check @/src/index.ts ") + }) + + it("should generate correct replacement text for folders", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ path: "src/components", type: "folder" }) + const lineText = "@comp" + const replacement = trigger.getReplacementText(item, lineText, 0) + + expect(replacement).toBe("@/src/components ") + }) + + it("should preserve full path in replacement text", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ + path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx", + type: "file", + }) + const lineText = "Fix @Pick" + const replacement = trigger.getReplacementText(item, lineText, 4) + + // Verify the full path is included without truncation + expect(replacement).toBe("Fix @/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx ") + // Verify last character 'x' is present + expect(replacement).toContain("PickerSelect.tsx ") + expect(replacement.trim().endsWith(".tsx")).toBe(true) + }) + + it("should render file items correctly", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ path: "src/index.ts", type: "file" }) + const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + + // Verify the path is present in the rendered output + expect(lastFrame()).toContain("src/index.ts") + }) + + it("should render folder items correctly", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ path: "src/components", type: "folder" }) + const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + + // Verify the path is present in the rendered output + expect(lastFrame()).toContain("src/components") + }) + + it("should render full path without truncation in UI", () => { + const trigger = createFileTrigger({ + onSearch: () => {}, + getResults: () => [], + }) + + const item = toFileResult({ + path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx", + type: "file", + }) + const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + + const output = lastFrame() + // Verify the full path is rendered without truncation + expect(output).toContain("PickerSelect.tsx") + // Verify the last character 'x' is present + expect(output).toContain("x") + // Verify no truncation occurred + expect(output).not.toMatch(/PickerSelect\.ts[^x]/) + }) + }) + + describe("toFileResult", () => { + it("should convert file search result to FileResult", () => { + const result = toFileResult({ path: "src/index.ts", type: "file" }) + + expect(result).toEqual({ + key: "src/index.ts", + path: "src/index.ts", + type: "file", + }) + }) + + it("should preserve label", () => { + const result = toFileResult({ + path: "src/index.ts", + type: "file", + label: "Main entry", + }) + + expect(result).toEqual({ + key: "src/index.ts", + path: "src/index.ts", + type: "file", + label: "Main entry", + }) + }) + }) +}) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx index 9108211c56..528fdfde7e 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx @@ -1,5 +1,7 @@ import { Box, Text } from "ink" +import Fuzzysort from "fuzzysort" +import { Icon } from "../../Icon.js" import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" /** @@ -37,12 +39,34 @@ export interface FileTriggerConfig { * This trigger activates when the user types @ followed by text, * and allows selecting files to insert as @/path references. * + * The file trigger uses async data fetching: + * - search() triggers the API call and returns [] immediately + * - When API responds, App.tsx calls forceRefresh() + * - refreshResults() then returns the actual results from the store + * * @param config - Configuration for the trigger * @returns AutocompleteTrigger for file mentions */ export function createFileTrigger(config: FileTriggerConfig): AutocompleteTrigger { const { onSearch, getResults } = config + // Helper function to get results and apply fuzzy sorting + function getResultsWithFuzzySort(query: string): FileResult[] { + const results = getResults() + + // Sort results by fuzzy match score (best matches first) + if (!query || results.length === 0) { + return results + } + + const fuzzyResults = Fuzzysort.go(query, results, { + key: "path", + threshold: -10000, // Include all results + }) + + return fuzzyResults.map((result) => result.obj) + } + return { id: "file", triggerChar: "@", @@ -73,19 +97,31 @@ export function createFileTrigger(config: FileTriggerConfig): AutocompleteTrigge }, search: (query: string): FileResult[] => { - // Trigger the external search + // Trigger the external async search onSearch(query) - // Return current results from store - // Results will update asynchronously and trigger a re-render - return getResults() + + // Return empty immediately - don't bother calling getResults() since + // we know the async API hasn't responded yet. + // When results arrive, App.tsx will call forceRefresh() which uses + // refreshResults() to get the actual data from the store. + return [] + }, + + // refreshResults: Get current results without triggering a new API call + // This is used by forceRefresh when async results arrive + refreshResults: (query: string): FileResult[] => { + return getResultsWithFuzzySort(query) }, renderItem: (item: FileResult, isSelected: boolean) => { - const displayPath = item.type === "folder" ? `${item.path}/` : item.path + const iconName = item.type === "folder" ? "folder" : "file" + const color = isSelected ? "cyan" : item.type === "folder" ? "blue" : undefined return ( - {displayPath} + + + {item.path} ) }, diff --git a/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx new file mode 100644 index 0000000000..1680d6906f --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx @@ -0,0 +1,128 @@ +import { Box, Text } from "ink" +import fuzzysort from "fuzzysort" + +import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" + +/** + * Mode result type. + * Extends AutocompleteItem with mode-specific properties. + */ +export interface ModeResult extends AutocompleteItem { + /** Mode slug (e.g., "code", "architect") */ + slug: string + /** Mode display name */ + name: string + /** Optional description of the mode */ + description?: string + /** Optional icon for the mode */ + icon?: string +} + +/** + * Props for creating a mode trigger + */ +export interface ModeTriggerConfig { + /** + * Get all available modes for filtering. + * Modes are filtered locally using fuzzy search. + */ + getModes: () => ModeResult[] + /** + * Maximum number of results to show. + * @default 20 + */ + maxResults?: number +} + +/** + * Create a mode trigger for ! mode switching. + * + * This trigger activates when the user types ! at the start of a line, + * and allows selecting modes with local fuzzy filtering. + * + * @param config - Configuration for the trigger + * @returns AutocompleteTrigger for mode switching + */ +export function createModeTrigger(config: ModeTriggerConfig): AutocompleteTrigger { + const { getModes, maxResults = 20 } = config + + return { + id: "mode", + 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 (mode selection 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): ModeResult[] => { + const allModes = getModes() + + if (query.length === 0) { + // Show all modes when just "!" is typed + return allModes.slice(0, maxResults) + } + + // Fuzzy search by mode name and slug + const results = fuzzysort.go(query, allModes, { + keys: ["name", "slug"], + limit: maxResults, + threshold: -10000, // Be lenient with matching + }) + + return results.map((result) => result.obj) + }, + + renderItem: (item: ModeResult, isSelected: boolean) => { + return ( + + + {item.name} + {item.description && - {item.description}} + + + ) + }, + + getReplacementText: (_item: ModeResult, _lineText: string, _triggerIndex: number): string => { + // Replace the entire input with just a space (mode will be switched via message) + // This clears the picker trigger from the input + return "" + }, + + emptyMessage: "No matching modes found", + debounceMs: 150, + } +} + +/** + * Convert external mode data to ModeResult. + * Use this to adapt modes from the store to the trigger's expected type. + */ +export function toModeResult(mode: { slug: string; name: string; description?: string; icon?: string }): ModeResult { + return { + key: mode.slug, + slug: mode.slug, + name: mode.name, + description: mode.description, + icon: mode.icon, + } +} diff --git a/apps/cli/src/ui/components/autocomplete/triggers/index.ts b/apps/cli/src/ui/components/autocomplete/triggers/index.ts index d172d0b4fd..9faf2ed66f 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/index.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/index.ts @@ -10,3 +10,5 @@ export { type SlashCommandResult, type SlashCommandTriggerConfig, } from "./SlashCommandTrigger.js" + +export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js" diff --git a/apps/cli/src/ui/components/autocomplete/types.ts b/apps/cli/src/ui/components/autocomplete/types.ts index d3ab41cf2b..c5a27a72c6 100644 --- a/apps/cli/src/ui/components/autocomplete/types.ts +++ b/apps/cli/src/ui/components/autocomplete/types.ts @@ -60,6 +60,15 @@ export interface AutocompleteTrigger T[] | Promise + /** + * Get current results without triggering a new search. + * Used for refreshing results when async data arrives. + * If not provided, forceRefresh will fall back to search(). + * @param query - The search query for filtering + * @returns Array of matching items from current data + */ + refreshResults?: (query: string) => T[] | Promise + /** * Render a single item in the picker dropdown. * @param item - The item to render @@ -124,4 +133,6 @@ export interface AutocompletePickerActions { navigateUp: () => void /** Navigate selection down */ navigateDown: () => void + /** Force refresh the current search results (for async data that arrived after initial search) */ + forceRefresh: () => void } diff --git a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts index a390ec3ce4..11682bc8bc 100644 --- a/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts +++ b/apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts @@ -100,11 +100,12 @@ export function useAutocompletePicker( return } - // Set loading state immediately + // Set loading state immediately and open picker setState((prev) => ({ ...prev, activeTrigger: foundTrigger, isLoading: true, + isOpen: true, // Open immediately when trigger is detected triggerInfo: foundTriggerInfo, })) @@ -125,7 +126,7 @@ export function useAutocompletePicker( ...prev, results, selectedIndex: 0, - isOpen: results.length > 0 || query.length > 0, + isOpen: true, // Keep open - user can close with Escape isLoading: false, } }) @@ -236,6 +237,75 @@ export function useAutocompletePicker( }) }, []) + /** + * Force refresh the current search results. + * This is used when external async data (like file search results) arrives + * after the initial search returned empty. + * Uses refreshResults if available to avoid triggering new API calls. + * + * IMPORTANT: We must find the current trigger from the `triggers` array, + * not use `state.activeTrigger`, because the triggers array is recreated + * with fresh closures when external data changes. + */ + const forceRefresh = useCallback(() => { + const { activeTrigger, triggerInfo } = state + + // Only refresh if picker is open and we have an active trigger + if (!activeTrigger || !triggerInfo) { + return + } + + // CRITICAL: Find the CURRENT trigger from the triggers array + // The state.activeTrigger holds a stale closure, but triggers array has fresh closures + const currentTrigger = triggers.find((t) => t.id === activeTrigger.id) + if (!currentTrigger) { + return + } + + const { query } = triggerInfo + + // Use refreshResults if available (doesn't trigger new API call) + // Fall back to search() if refreshResults is not implemented + const refreshFn = currentTrigger.refreshResults ?? currentTrigger.search + + try { + const results = refreshFn(query) + + // Handle both sync and async search results + if (results instanceof Promise) { + results.then((asyncResults) => { + setState((prev) => { + // Only update if still the same trigger + if (prev.activeTrigger?.id !== activeTrigger.id) { + return prev + } + return { + ...prev, + results: asyncResults, + selectedIndex: 0, + isLoading: false, + } + }) + }) + } else { + setState((prev) => { + // Only update if still the same trigger + if (prev.activeTrigger?.id !== activeTrigger.id) { + return prev + } + return { + ...prev, + results, + selectedIndex: 0, + isLoading: false, + } + }) + } + } catch (_error) { + // Silently fail on refresh errors + } + }, [state, triggers]) + const actions: AutocompletePickerActions = { handleInputChange, handleSelect, @@ -243,6 +313,7 @@ export function useAutocompletePicker( handleIndexChange, navigateUp, navigateDown, + forceRefresh, } return [state, actions] diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index 3111dde4b5..9e67c69999 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -1,8 +1,8 @@ import { create } from "zustand" -import type { TokenUsage, ProviderSettings } from "@roo-code/types" +import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" -import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult } from "./types.js" +import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult, ModeResult } from "./types.js" /** * RouterModels type for context window lookup. @@ -31,6 +31,10 @@ interface CLIState { // Autocomplete data (from API/extension) fileSearchResults: FileSearchResult[] allSlashCommands: SlashCommandResult[] + availableModes: ModeResult[] + + // Current mode (updated reactively when mode changes) + currentMode: string | null // Token usage metrics (from getApiMetrics) tokenUsage: TokenUsage | null @@ -38,6 +42,10 @@ interface CLIState { // Model info for context window lookup routerModels: RouterModels | null apiConfiguration: ProviderSettings | null + + // Todo list tracking + currentTodos: TodoItem[] + previousTodos: TodoItem[] } interface CLIActions { @@ -56,11 +64,18 @@ interface CLIActions { // Autocomplete data actions setFileSearchResults: (results: FileSearchResult[]) => void setAllSlashCommands: (commands: SlashCommandResult[]) => void + setAvailableModes: (modes: ModeResult[]) => void + + // Current mode action + setCurrentMode: (mode: string | null) => void // Metrics actions setTokenUsage: (usage: TokenUsage | null) => void setRouterModels: (models: RouterModels | null) => void setApiConfiguration: (config: ProviderSettings | null) => void + + // Todo actions + setTodos: (todos: TodoItem[]) => void } const initialState: CLIState = { @@ -72,9 +87,13 @@ const initialState: CLIState = { error: null, fileSearchResults: [], allSlashCommands: [], + availableModes: [], + currentMode: null, tokenUsage: null, routerModels: null, apiConfiguration: null, + currentTodos: [], + previousTodos: [], } export const useCLIStore = create((set) => ({ @@ -129,7 +148,14 @@ export const useCLIStore = create((set) => ({ reset: () => set(initialState), setFileSearchResults: (results) => set({ fileSearchResults: results }), setAllSlashCommands: (commands) => set({ allSlashCommands: commands }), + setAvailableModes: (modes) => set({ availableModes: modes }), + setCurrentMode: (mode) => set({ currentMode: mode }), setTokenUsage: (usage) => set({ tokenUsage: usage }), setRouterModels: (models) => set({ routerModels: models }), setApiConfiguration: (config) => set({ apiConfiguration: config }), + setTodos: (todos) => + set((state) => ({ + previousTodos: state.currentTodos, + currentTodos: todos, + })), })) diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index 1b19145dce..907d0eda42 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -1,4 +1,7 @@ -import type { ClineAsk, ClineSay } from "@roo-code/types" +import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types" + +// Re-export TodoItem for convenience +export type { TodoItem } export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking" @@ -41,6 +44,10 @@ export interface TUIMessage { hasPendingToolCalls?: boolean partial?: boolean originalType?: SayType | AskType + /** TODO items for update_todo_list tool messages */ + todos?: TodoItem[] + /** Previous TODO items for diff display */ + previousTodos?: TodoItem[] } export interface PendingAsk { @@ -80,3 +87,10 @@ export interface SlashCommandResult { argumentHint?: string source: "global" | "project" | "built-in" } + +export interface ModeResult { + slug: string + name: string + description?: string + icon?: string +} diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index a558a62e83..63c3348dd0 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ environment: "node", watch: false, testTimeout: 120_000, // 2m for integration tests. - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 012442ac54..0f95a20c0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@types/react': specifier: ^18.3.23 version: 18.3.23 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@18.3.23) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -6801,6 +6804,15 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': ^18.3.23 + peerDependenciesMeta: + '@types/react': + optional: true + ink@6.6.0: resolution: {integrity: sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==} engines: {node: '>=20'} @@ -17064,6 +17076,10 @@ snapshots: ini@1.3.8: optional: true + ink-testing-library@4.0.0(@types/react@18.3.23): + optionalDependencies: + '@types/react': 18.3.23 + ink@6.6.0(@types/react@18.3.23)(react@19.2.3): dependencies: '@alcalzone/ansi-tokenize': 0.2.3 diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 35bf08e048..17b2190cb2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2870,6 +2870,29 @@ export const webviewMessageHandler = async ( } break } + case "requestModes": { + try { + const modes = await provider.getModes() + await provider.postMessageToWebview({ + type: "modes", + modes, + }) + } catch (error) { + provider.log(`Error fetching modes: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + // Send empty array on error + await provider.postMessageToWebview({ + type: "modes", + modes: [], + }) + } + break + } + case "switchMode": { + if (message.mode) { + await provider.handleModeSwitch(message.mode as Mode) + } + break + } case "openCommandFile": { try { if (message.text) {