mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
More progress
This commit is contained in:
parent
a590932727
commit
c9d52349d5
29 changed files with 1986 additions and 32 deletions
67
.roo/rules-debug/cli.md
Normal file
67
.roo/rules-debug/cli.md
Normal 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."
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
165
apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts
Normal file
165
apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
apps/cli/src/ui/components/Icon.tsx
Normal file
144
apps/cli/src/ui/components/Icon.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
142
apps/cli/src/ui/components/TodoChangeDisplay.tsx
Normal file
142
apps/cli/src/ui/components/TodoChangeDisplay.tsx
Normal 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)
|
||||
179
apps/cli/src/ui/components/TodoDisplay.tsx
Normal file
179
apps/cli/src/ui/components/TodoDisplay.tsx
Normal 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)
|
||||
162
apps/cli/src/ui/components/__tests__/Icon.test.tsx
Normal file
162
apps/cli/src/ui/components/__tests__/Icon.test.tsx
Normal 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("")
|
||||
})
|
||||
})
|
||||
})
|
||||
149
apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx
Normal file
149
apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx
Normal 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]")
|
||||
})
|
||||
})
|
||||
138
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal file
138
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -53,4 +53,8 @@ export {
|
|||
toSlashCommandResult,
|
||||
type SlashCommandResult,
|
||||
type SlashCommandTriggerConfig,
|
||||
createModeTrigger,
|
||||
toModeResult,
|
||||
type ModeResult,
|
||||
type ModeTriggerConfig,
|
||||
} from "./triggers/index.js"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
128
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal file
128
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -10,3 +10,5 @@ export {
|
|||
type SlashCommandResult,
|
||||
type SlashCommandTriggerConfig,
|
||||
} from "./SlashCommandTrigger.js"
|
||||
|
||||
export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
16
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue