More progress

This commit is contained in:
cte 2026-01-06 21:28:27 -08:00
parent a590932727
commit c9d52349d5
29 changed files with 1986 additions and 32 deletions

67
.roo/rules-debug/cli.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<any>[] => {
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<string, unknown>
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<Record<string, unknown>> | 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<string, Record<string, { contextWindow?: number }>> | 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({
<Box flexShrink={0}>
<Header
model={model}
mode={mode}
mode={currentMode || mode}
cwd={workspacePath}
reasoningEffort={reasoningEffort}
version={version}
@ -1096,6 +1189,12 @@ function formatToolOutput(toolInfo: Record<string, unknown>): 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, unknown>): string {
}
}
}
/**
* Parse TODO items from tool info
* Handles both array format and markdown checklist string format
*/
function parseTodosFromToolInfo(toolInfo: Record<string, unknown>): 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<string, unknown>
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
}

View file

@ -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) {
</Box>
)
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 (
<Box flexDirection="column">
<TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
<Text>
<Newline />
</Text>
</Box>
)
}
let toolContent = message.toolDisplayOutput || content
// Replace tab characters with spaces to prevent terminal width miscalculation

View file

@ -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<IconName, { nerd: string; fallback: string }> = {
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<TextProps, "children"> {
/** 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
* <Icon name="folder" color="blue" />
* <Icon name="file" />
* <Icon name="check" color="green" useNerdFont={false} />
* ```
*/
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 (
<Box width={width}>
<Text color={color} {...textProps}>
{icon}
</Text>
</Box>
)
}
// For BMP characters (no surrogate pairs), render directly
return (
<Text color={color} {...textProps}>
{icon}
</Text>
)
}
/**
* 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
}

View file

@ -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<DOMElement>(null)
@ -185,7 +188,7 @@ export function ScrollArea({
height: height,
scrollTop: 0,
innerHeight: 0,
autoScroll: true,
autoScroll: autoScrollProp,
})
const innerRef = useRef<DOMElement>(null)

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header with progress summary */}
<Box>
<Text color={theme.toolHeader} bold>
☑ TODO {isInitialState ? "List" : "Updated"}
</Text>
<Text color={theme.dimText}>
{" "}
({completedCount}/{totalCount})
</Text>
</Box>
{/* Changed items */}
<Box flexDirection="column" paddingLeft={2}>
{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 (
<Box key={todo.id || `todo-${index}`}>
<Text color={color}>
{icon} {todo.content}
</Text>
{changeLabel && (
<Text color={theme.dimText} dimColor>
{" "}
[{changeLabel}]
</Text>
)}
</Box>
)
})}
</Box>
</Box>
)
}
export default memo(TodoChangeDisplay)

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Text color={theme.toolHeader} bold>
☑ {title}
</Text>
</Box>
{/* Border top */}
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
</Box>
{/* TODO items */}
<Box flexDirection="column" paddingLeft={1}>
{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 (
<Box key={todo.id || `todo-${index}`}>
<Text color={color}>
{icon} {todo.content}
</Text>
{statusChanged && (
<Text color={theme.dimText} dimColor>
{" "}
[
{todo.status === "completed"
? "done"
: todo.status === "in_progress"
? "started"
: "reset"}
]
</Text>
)}
{isNew && (
<Text color={theme.dimText} dimColor>
{" "}
[new]
</Text>
)}
</Box>
)
})}
</Box>
{/* Progress bar and stats */}
{showProgress && (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
</Box>
<Box paddingLeft={1}>
<ProgressBar value={completedCount} max={totalCount} width={16} />
<Text color={theme.dimText}>
{" "}
{completedCount}/{totalCount} completed
{inProgressCount > 0 && `, ${inProgressCount} in progress`}
</Text>
</Box>
</Box>
)}
</Box>
)
}
export default memo(TodoDisplay)

View file

@ -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(<Icon name="folder" />)
// Should render something (either nerd font or fallback)
expect(lastFrame()).toBeDefined()
})
it("should render file icon", () => {
const { lastFrame } = render(<Icon name="file" />)
expect(lastFrame()).toBeDefined()
})
it("should render check icon", () => {
const { lastFrame } = render(<Icon name="check" />)
expect(lastFrame()).toBeDefined()
})
it("should render cross icon", () => {
const { lastFrame } = render(<Icon name="cross" />)
expect(lastFrame()).toBeDefined()
})
it("should apply color prop", () => {
const { lastFrame } = render(<Icon name="file" color="blue" />)
expect(lastFrame()).toBeDefined()
})
it("should return null for unknown icon name", () => {
// @ts-expect-error - testing invalid icon name
const { lastFrame } = render(<Icon name="unknown-icon" />)
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(<Icon name="folder" useNerdFont={true} />)
// 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(<Icon name="folder" useNerdFont={false} />)
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("")
})
})
})

View file

@ -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(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={[]} newTodos={[]} />)
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(<TodoChangeDisplay previousTodos={todos} newTodos={todos} />)
// 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(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
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(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
const output = lastFrame()
// Should recognize as the same task that changed status
expect(output).toContain("Same content task")
expect(output).toContain("[done]")
})
})

View file

@ -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(<TodoDisplay todos={mockTodos} />)
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(<TodoDisplay todos={mockTodos} showProgress={true} />)
const output = lastFrame()
// Check progress stats
expect(output).toContain("2/5 completed")
})
it("hides progress bar when showProgress is false", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} showProgress={false} />)
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(<TodoDisplay todos={[]} />)
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(
<TodoDisplay todos={newTodos} previousTodos={previousTodos} showChangesOnly={true} />,
)
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(<TodoDisplay todos={newTodos} previousTodos={previousTodos} />)
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(<TodoDisplay todos={newTodos} previousTodos={previousTodos} />)
const output = lastFrame()
expect(output).toContain("New Task")
expect(output).toContain("[new]")
})
it("uses custom title when provided", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} title="My Custom Title" />)
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(<TodoDisplay todos={todosWithMultipleInProgress} showProgress={true} />)
const output = lastFrame()
expect(output).toContain("1/4 completed")
expect(output).toContain("2 in progress")
})
})

View file

@ -38,6 +38,8 @@ export interface AutocompleteInputHandle<T extends AutocompleteItem = Autocomple
handleIndexChange: (index: number) => 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<T extends AutocompleteItem>(
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<T extends AutocompleteItem>(
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 (

View file

@ -103,7 +103,8 @@ export function PickerSelect<T extends AutocompleteItem>({
isActive={false}
showScrollbar={true}
scrollToLine={selectedIndex}
scrollToLineTrigger={scrollTrigger}>
scrollToLineTrigger={scrollTrigger}
autoScroll={false}>
{results.map((result, index) => {
const isSelected = index === selectedIndex
return <Box key={result.key}>{renderItem(result, isSelected)}</Box>

View file

@ -53,4 +53,8 @@ export {
toSlashCommandResult,
type SlashCommandResult,
type SlashCommandTriggerConfig,
createModeTrigger,
toModeResult,
type ModeResult,
type ModeTriggerConfig,
} from "./triggers/index.js"

View file

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

View file

@ -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<FileResult> {
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 (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{displayPath}</Text>
<Icon name={iconName} color={color} />
<Text> </Text>
<Text color={color}>{item.path}</Text>
</Box>
)
},

View file

@ -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<ModeResult> {
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 (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
{item.name}
{item.description && <Text dimColor> - {item.description}</Text>}
</Text>
</Box>
)
},
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,
}
}

View file

@ -10,3 +10,5 @@ export {
type SlashCommandResult,
type SlashCommandTriggerConfig,
} from "./SlashCommandTrigger.js"
export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js"

View file

@ -60,6 +60,15 @@ export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteIt
*/
search: (query: string) => T[] | Promise<T[]>
/**
* 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<T[]>
/**
* Render a single item in the picker dropdown.
* @param item - The item to render
@ -124,4 +133,6 @@ export interface AutocompletePickerActions<T extends AutocompleteItem> {
navigateUp: () => void
/** Navigate selection down */
navigateDown: () => void
/** Force refresh the current search results (for async data that arrived after initial search) */
forceRefresh: () => void
}

View file

@ -100,11 +100,12 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
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<T extends AutocompleteItem>(
...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<T extends AutocompleteItem>(
})
}, [])
/**
* 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<T> = {
handleInputChange,
handleSelect,
@ -243,6 +313,7 @@ export function useAutocompletePicker<T extends AutocompleteItem>(
handleIndexChange,
navigateUp,
navigateDown,
forceRefresh,
}
return [state, actions]

View file

@ -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<CLIState & CLIActions>((set) => ({
@ -129,7 +148,14 @@ export const useCLIStore = create<CLIState & CLIActions>((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,
})),
}))

View file

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

View file

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

16
pnpm-lock.yaml generated
View file

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

View file

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