diff --git a/apps/cli/src/__tests__/globalCommands.test.ts b/apps/cli/src/__tests__/globalCommands.test.ts index a3b1261861..9d7251db61 100644 --- a/apps/cli/src/__tests__/globalCommands.test.ts +++ b/apps/cli/src/__tests__/globalCommands.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect } from "vitest" import { + type GlobalCommand, + type GlobalCommandAction, GLOBAL_COMMANDS, getGlobalCommand, getGlobalCommandsForAutocomplete, - type GlobalCommand, - type GlobalCommandAction, } from "../globalCommands.js" describe("globalCommands", () => { diff --git a/apps/cli/src/__tests__/store.test.ts b/apps/cli/src/__tests__/store.test.ts index 2cd628df2e..bbb10a7c16 100644 --- a/apps/cli/src/__tests__/store.test.ts +++ b/apps/cli/src/__tests__/store.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect, beforeEach } from "vitest" import { useCLIStore } from "../ui/store.js" describe("useCLIStore", () => { @@ -43,8 +42,8 @@ describe("useCLIStore", () => { const store = useCLIStore.getState() store.addMessage({ id: "1", role: "user", content: "test" }) store.setTaskHistory([{ id: "task1", task: "test", workspace: "/test", ts: Date.now() }]) - store.setAvailableModes([{ slug: "code", name: "Code" }]) - store.setAllSlashCommands([{ name: "test", source: "global" as const }]) + store.setAvailableModes([{ key: "code", slug: "code", name: "Code" }]) + store.setAllSlashCommands([{ key: "test", name: "test", source: "global" as const }]) store.setIsResumingTask(true) store.setLoading(true) store.setHasStartedTask(true) @@ -115,8 +114,8 @@ describe("useCLIStore", () => { it("should PRESERVE availableModes", () => { const modes = [ - { slug: "code", name: "Code", description: "Code mode" }, - { slug: "architect", name: "Architect", description: "Architect mode" }, + { key: "code", slug: "code", name: "Code", description: "Code mode" }, + { key: "architect", slug: "architect", name: "Architect", description: "Architect mode" }, ] useCLIStore.getState().setAvailableModes(modes) @@ -127,8 +126,8 @@ describe("useCLIStore", () => { it("should PRESERVE allSlashCommands", () => { const commands = [ - { name: "new", description: "New task", source: "global" as const }, - { name: "help", description: "Get help", source: "built-in" as const }, + { key: "new", name: "new", description: "New task", source: "global" as const }, + { key: "help", name: "help", description: "Get help", source: "built-in" as const }, ] useCLIStore.getState().setAllSlashCommands(commands) @@ -139,8 +138,8 @@ describe("useCLIStore", () => { it("should PRESERVE fileSearchResults", () => { const results = [ - { path: "file1.ts", type: "file" as const }, - { path: "file2.ts", type: "file" as const }, + { key: "file1", path: "file1.ts", type: "file" as const }, + { key: "file2", path: "file2.ts", type: "file" as const }, ] useCLIStore.getState().setFileSearchResults(results) @@ -184,8 +183,8 @@ describe("useCLIStore", () => { // Step 1: Initial state with task history and modes from webviewDidLaunch store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }]) - store().setAvailableModes([{ slug: "code", name: "Code" }]) - store().setAllSlashCommands([{ name: "new", source: "global" as const }]) + store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }]) + store().setAllSlashCommands([{ key: "new", name: "new", source: "global" as const }]) // Step 2: User starts a new task store().setHasStartedTask(true) @@ -255,7 +254,7 @@ describe("useCLIStore", () => { // Set up both task-specific and global state store().addMessage({ id: "1", role: "user", content: "test" }) store().setTaskHistory([{ id: "t1", task: "task", workspace: "/", ts: Date.now() }]) - store().setAvailableModes([{ slug: "code", name: "Code" }]) + store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }]) // Use resetForTaskSwitch store().resetForTaskSwitch() diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 7ad39b376b..9a9d1f2c31 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -4,21 +4,38 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react" import { EventEmitter } from "events" import { randomUUID } from "crypto" -import type { ClineMessage, TodoItem, WebviewMessage } from "@roo-code/types" -// Import only message-utils to avoid custom-tools dependencies (execa/child_process) +import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem, WebviewMessage } from "@roo-code/types" import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" + +import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js" +import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js" import { toolInspectorLog, clearToolInspectorLog } from "../utils/toolInspectorLogger.js" import { arePathsEqual } from "../utils/pathUtils.js" +import { getContextWindow } from "../utils/getContextWindow.js" + +import type { AppProps, TUIMessage, PendingAsk, View, ToolData } from "./types.js" + +import * as theme from "./utils/theme.js" +import { matchesGlobalSequence } from "./utils/globalInputSequences.js" import { useCLIStore } from "./store.js" -import { getContextWindow } from "../utils/getContextWindow.js" + +import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js" +import { useToast } from "./hooks/useToast.js" + import Header from "./components/Header.js" import ChatHistoryItem from "./components/ChatHistoryItem.js" import LoadingText from "./components/LoadingText.js" import ToastDisplay from "./components/ToastDisplay.js" import TodoDisplay from "./components/TodoDisplay.js" -import { useToast } from "./hooks/useToast.js" import { + type AutocompleteInputHandle, + type AutocompletePickerState, + type AutocompleteTrigger, + type HistoryResult, + type FileResult, + type SlashCommandResult, + type ModeResult, AutocompleteInput, PickerSelect, createFileTrigger, @@ -30,41 +47,12 @@ import { toSlashCommandResult, toModeResult, toHistoryResult, - type AutocompleteInputHandle, - type AutocompletePickerState, - type AutocompleteTrigger, - type FileResult, - type SlashCommandResult as SlashCommandItem, - type ModeResult as ModeItem, - type HistoryResult, } from "./components/autocomplete/index.js" import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" import ScrollIndicator from "./components/ScrollIndicator.js" -import { TerminalSizeProvider, useTerminalSize } from "./hooks/TerminalSizeContext.js" -import * as theme from "./utils/theme.js" -import { matchesGlobalSequence } from "./utils/globalInputSequences.js" -import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js" -import type { - AppProps, - TUIMessage, - PendingAsk, - SayType, - AskType, - View, - FileSearchResult, - SlashCommandResult, - ModeResult, - TaskHistoryItem, - ToolData, -} from "./types.js" -import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js" -// Layout constants -const PICKER_HEIGHT = 10 // Max height for picker when open +const PICKER_HEIGHT = 10 -/** - * Interface for the extension host that the TUI interacts with - */ interface ExtensionHostInterface extends EventEmitter { activate(): Promise runTask(prompt: string): Promise @@ -73,7 +61,7 @@ interface ExtensionHostInterface extends EventEmitter { } export interface TUIAppProps extends AppProps { - /** Extension host factory - allows dependency injection for testing */ + /** Extension host factory - allows dependency injection for testing. */ createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface } @@ -600,13 +588,14 @@ function AppInner({ // Map extension say messages to TUI messages const handleSayMessage = useCallback( - (ts: number, say: SayType, text: string, partial: boolean) => { + (ts: number, say: ClineSay, text: string, partial: boolean) => { const messageId = ts.toString() const isResuming = useCLIStore.getState().isResumingTask if (say === "checkpoint_saved") { return } + if (say === "api_req_started" && !verbose) { return } @@ -639,65 +628,60 @@ function AppInner({ toolName = "execute_command" toolDisplayName = "bash" toolDisplayOutput = text - // Create toolData for command output, including the pending command if available const trackedCommand = pendingCommandRef.current toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length }) - toolData = { - tool: "execute_command", - command: trackedCommand || undefined, - output: text, - } - // Clear the pending command after using it + toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } pendingCommandRef.current = null - } else if (say === "tool") { - role = "tool" - try { - const toolInfo = JSON.parse(text) + // } else if (say === "tool") { + // role = "tool" - // Log tool payload for inspection - toolInspectorLog("say:tool", { - ts, - rawText: text, - parsedToolInfo: toolInfo, - partial, - }) + // try { + // const toolInfo = JSON.parse(text) - toolName = toolInfo.tool - toolDisplayName = toolInfo.tool - toolDisplayOutput = formatToolOutput(toolInfo) - // Extract structured toolData for rich rendering - toolData = extractToolData(toolInfo) + // // Log tool payload for inspection + // toolInspectorLog("say:tool", { + // ts, + // rawText: text, + // parsedToolInfo: toolInfo, + // partial, + // }) - // 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) + // toolName = toolInfo.tool + // toolDisplayName = toolInfo.tool + // toolDisplayOutput = formatToolOutput(toolInfo) + // // Extract structured toolData for rich rendering + // toolData = extractToolData(toolInfo) - seenMessageIds.current.add(messageId) + // // 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) - addMessage({ - id: messageId, - role: "tool", - content: text || "", - toolName, - toolDisplayName, - toolDisplayOutput, - partial, - originalType: say, - todos, - previousTodos: prevTodos, - toolData, - }) - return - } - } - } catch { - toolDisplayOutput = text - } - } else if (say === "reasoning" || say === "thinking") { + // seenMessageIds.current.add(messageId) + + // addMessage({ + // id: messageId, + // role: "tool", + // content: text || "", + // toolName, + // toolDisplayName, + // toolDisplayOutput, + // partial, + // originalType: say, + // todos, + // previousTodos: prevTodos, + // toolData, + // }) + // return + // } + // } + // } catch { + // toolDisplayOutput = text + // } + } else if (say === "reasoning") { role = "thinking" } @@ -720,7 +704,7 @@ function AppInner({ // Handle extension ask messages const handleAskMessage = useCallback( - (ts: number, ask: AskType, text: string, partial: boolean) => { + (ts: number, ask: ClineAsk, text: string, partial: boolean) => { const messageId = ts.toString() if (partial) { @@ -912,34 +896,38 @@ function AppInner({ // Handle extension messages const handleExtensionMessage = useCallback( - (message: unknown) => { - const msg = message as Record - + (msg: ExtensionMessage) => { if (msg.type === "state") { - const state = msg.state as Record - if (!state) return + const state = msg.state + + if (!state) { + return + } + + // Extract and update current mode from state. + const newMode = state.mode - // Extract and update current mode from state - const newMode = state.mode as string | undefined if (newMode) { setCurrentMode(newMode) } - // Extract and update task history from state - const newTaskHistory = state.taskHistory as TaskHistoryItem[] | undefined + // Extract and update task history from state. + const newTaskHistory = state.taskHistory + if (newTaskHistory && Array.isArray(newTaskHistory)) { setTaskHistory(newTaskHistory) } - const clineMessages = state.clineMessages as Array> | undefined + const clineMessages = state.clineMessages + if (clineMessages) { for (const clineMsg of clineMessages) { - const ts = clineMsg.ts as number - const type = clineMsg.type as string - const say = clineMsg.say as SayType | undefined - const ask = clineMsg.ask as AskType | undefined - const text = (clineMsg.text as string) || "" - const partial = (clineMsg.partial as boolean) || false + const ts = clineMsg.ts + const type = clineMsg.type + const say = clineMsg.say + const ask = clineMsg.ask + const text = clineMsg.text || "" + const partial = clineMsg.partial || false if (type === "say" && say) { handleSayMessage(ts, say, text, partial) @@ -948,12 +936,13 @@ function AppInner({ } } - // Compute token usage metrics from clineMessages - // Skip first message (task prompt) as per webview UI pattern + // Compute token usage metrics from clineMessages. + // Skip first message (task prompt) as per webview UI pattern. if (clineMessages.length > 1) { const processed = consolidateApiRequests( consolidateCommands(clineMessages.slice(1) as ClineMessage[]), ) + const metrics = consolidateTokenUsage(processed) setTokenUsage(metrics) } @@ -965,15 +954,18 @@ function AppInner({ useCLIStore.getState().setIsResumingTask(false) } } else if (msg.type === "messageUpdated") { - const clineMessage = msg.clineMessage as Record - if (!clineMessage) return + const clineMessage = msg.clineMessage - const ts = clineMessage.ts as number - const type = clineMessage.type as string - const say = clineMessage.say as SayType | undefined - const ask = clineMessage.ask as AskType | undefined - const text = (clineMessage.text as string) || "" - const partial = (clineMessage.partial as boolean) || false + if (!clineMessage) { + return + } + + const ts = clineMessage.ts + const type = clineMessage.type + const say = clineMessage.say + const ask = clineMessage.ask + const text = clineMessage.text || "" + const partial = clineMessage.partial || false if (type === "say" && say) { handleSayMessage(ts, say, text, partial) @@ -981,47 +973,14 @@ function AppInner({ handleAskMessage(ts, ask, text, partial) } } else if (msg.type === "fileSearchResults") { - const results = (msg.results as FileSearchResult[]) || [] - setFileSearchResults(results) + setFileSearchResults((msg.results as FileResult[]) || []) } else if (msg.type === "commands") { - const commands = - (msg.commands as Array<{ - name: string - description?: string - argumentHint?: string - source: "global" | "project" | "built-in" - }>) || [] - const slashCommands: SlashCommandResult[] = commands.map((cmd) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: cmd.source, - })) - setAllSlashCommands(slashCommands) + setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) } 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) + setAvailableModes((msg.modes as ModeResult[]) || []) } else if (msg.type === "routerModels") { - // Handle router models for context window lookup - const models = msg.models as Record> | undefined - if (models) { - setRouterModels(models) - } - } else if (msg.type === "apiConfiguration") { - // Handle API configuration for model identification - const config = msg.configuration as unknown - if (config) { - setApiConfiguration(config as import("@roo-code/types").ProviderSettings) + if (msg.routerModels) { + setRouterModels(msg.routerModels) } } }, @@ -1272,7 +1231,7 @@ function AppInner({ (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 + const modeItem = item as ModeResult // Send mode change message to extension if (hostRef.current) { @@ -1402,7 +1361,7 @@ function AppInner({ return pickerState.activeTrigger.renderItem } // Default render - return (item: FileResult | SlashCommandItem, isSelected: boolean) => ( + return (item: FileResult | SlashCommandResult, isSelected: boolean) => ( {item.key} diff --git a/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx b/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx index e8092971f1..85530fbfd1 100644 --- a/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx +++ b/apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx @@ -1,5 +1,4 @@ import { render } from "ink-testing-library" -import { describe, it, expect } from "vitest" import type { Toast } from "../../hooks/useToast.js" import ToastDisplay from "../ToastDisplay.js" diff --git a/apps/cli/src/ui/components/autocomplete/index.ts b/apps/cli/src/ui/components/autocomplete/index.ts index 288ceb194c..d4f78c821f 100644 --- a/apps/cli/src/ui/components/autocomplete/index.ts +++ b/apps/cli/src/ui/components/autocomplete/index.ts @@ -28,39 +28,14 @@ */ // Main components -export { AutocompleteInput, type AutocompleteInputProps, type AutocompleteInputHandle } from "./AutocompleteInput.js" -export { PickerSelect, type PickerSelectProps } from "./PickerSelect.js" +export { type AutocompleteInputProps, type AutocompleteInputHandle, AutocompleteInput } from "./AutocompleteInput.js" +export { type PickerSelectProps, PickerSelect } from "./PickerSelect.js" // Hook export { useAutocompletePicker } from "./useAutocompletePicker.js" // Types -export type { - AutocompleteItem, - AutocompleteTrigger, - AutocompletePickerState, - AutocompletePickerActions, - TriggerDetectionResult, -} from "./types.js" +export * from "./types.js" // Triggers -export { - createFileTrigger, - toFileResult, - type FileResult, - type FileTriggerConfig, - createSlashCommandTrigger, - toSlashCommandResult, - type SlashCommandResult, - type SlashCommandTriggerConfig, - createModeTrigger, - toModeResult, - type ModeResult, - type ModeTriggerConfig, - createHelpTrigger, - type HelpShortcutResult, - createHistoryTrigger, - toHistoryResult, - type HistoryResult, - type HistoryTriggerConfig, -} from "./triggers/index.js" +export * from "./triggers/index.js" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx deleted file mode 100644 index a2e2f1de8c..0000000000 --- a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -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 detect @ even without text after it", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const result = trigger.detectTrigger("@") - expect(result).toEqual({ query: "", triggerIndex: 0 }) - }) - - it("should not detect @ followed by space", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const result = trigger.detectTrigger("@ ") - expect(result).toBeNull() - }) - - it("should close picker when query contains space", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const result = trigger.detectTrigger("@file name") - expect(result).toBeNull() - }) - - it("should generate correct replacement text for files", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ path: "src/index.ts", type: "file" }) - const lineText = "Check @ind" - const replacement = trigger.getReplacementText(item, lineText, 6) - - expect(replacement).toBe("Check @/src/index.ts ") - }) - - it("should generate correct replacement text for folders", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ path: "src/components", type: "folder" }) - const lineText = "@comp" - const replacement = trigger.getReplacementText(item, lineText, 0) - - expect(replacement).toBe("@/src/components ") - }) - - it("should preserve full path in replacement text", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ - path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx", - type: "file", - }) - const lineText = "Fix @Pick" - const replacement = trigger.getReplacementText(item, lineText, 4) - - // Verify the full path is included without truncation - expect(replacement).toBe("Fix @/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx ") - // Verify last character 'x' is present - expect(replacement).toContain("PickerSelect.tsx ") - expect(replacement.trim().endsWith(".tsx")).toBe(true) - }) - - it("should render file items correctly", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ path: "src/index.ts", type: "file" }) - const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) - - // Verify the path is present in the rendered output - expect(lastFrame()).toContain("src/index.ts") - }) - - it("should render folder items correctly", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ path: "src/components", type: "folder" }) - const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) - - // Verify the path is present in the rendered output - expect(lastFrame()).toContain("src/components") - }) - - it("should render full path without truncation in UI", () => { - const trigger = createFileTrigger({ - onSearch: () => {}, - getResults: () => [], - }) - - const item = toFileResult({ - path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx", - type: "file", - }) - const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) - - const output = lastFrame() - // Verify the full path is rendered without truncation - expect(output).toContain("PickerSelect.tsx") - // Verify the last character 'x' is present - expect(output).toContain("x") - // Verify no truncation occurred - expect(output).not.toMatch(/PickerSelect\.ts[^x]/) - }) - }) - - describe("toFileResult", () => { - it("should convert file search result to FileResult", () => { - const result = toFileResult({ path: "src/index.ts", type: "file" }) - - expect(result).toEqual({ - key: "src/index.ts", - path: "src/index.ts", - type: "file", - }) - }) - - it("should preserve label", () => { - const result = toFileResult({ - path: "src/index.ts", - type: "file", - label: "Main entry", - }) - - expect(result).toEqual({ - key: "src/index.ts", - path: "src/index.ts", - type: "file", - label: "Main entry", - }) - }) - }) -}) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx index 58fc42df44..3dbb720009 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx @@ -4,16 +4,9 @@ import Fuzzysort from "fuzzysort" import { Icon } from "../../Icon.js" import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" -/** - * File search result type. - * Extends AutocompleteItem with file-specific properties. - */ export interface FileResult extends AutocompleteItem { - /** File or folder path */ path: string - /** Whether this is a file or folder */ type: "file" | "folder" - /** Optional display label */ label?: string } diff --git a/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx index 1680d6906f..7652d3b59c 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx @@ -3,34 +3,15 @@ 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 } @@ -114,7 +95,7 @@ export function createModeTrigger(config: ModeTriggerConfig): AutocompleteTrigge } /** - * Convert external mode data to ModeResult. + * Convert external mode data to ModeTriggerResult. * 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 { diff --git a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx index 7cfe0aa133..168965ddbc 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx @@ -2,37 +2,19 @@ import { Box, Text } from "ink" import fuzzysort from "fuzzysort" import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" +import { GlobalCommandAction } from "../../../../globalCommands.js" -/** - * Slash command result type. - * Extends AutocompleteItem with command-specific properties. - */ export interface SlashCommandResult extends AutocompleteItem { - /** Command name (without the leading /) */ name: string - /** Optional description of what the command does */ description?: string - /** Optional hint about command arguments */ argumentHint?: string - /** Source of the command */ source: "global" | "project" | "built-in" - /** Action to trigger for CLI global commands (only present for action commands) */ - action?: string + /** Action to trigger for CLI global commands (e.g., clearTask for /new) */ + action?: GlobalCommandAction } -/** - * Props for creating a slash command trigger - */ export interface SlashCommandTriggerConfig { - /** - * Get all available commands for filtering. - * Commands are filtered locally using fuzzy search. - */ getCommands: () => SlashCommandResult[] - /** - * Maximum number of results to show. - * @default 20 - */ maxResults?: number } diff --git a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/FileTrigger.test.tsx similarity index 67% rename from apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts rename to apps/cli/src/ui/components/autocomplete/triggers/__tests__/FileTrigger.test.tsx index 89013123f8..6e0f98bcaf 100644 --- a/apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/FileTrigger.test.tsx @@ -1,9 +1,6 @@ -import { describe, it, expect, vi } from "vitest" -import { - createFileTrigger, - toFileResult, - type FileResult, -} from "../../ui/components/autocomplete/triggers/FileTrigger.js" +import { render } from "ink-testing-library" + +import { createFileTrigger, toFileResult, type FileResult } from "../FileTrigger.js" describe("FileTrigger", () => { describe("toFileResult", () => { @@ -46,6 +43,11 @@ describe("FileTrigger", () => { }) }) + it("should detect @ trigger at start of line", () => { + const result = trigger.detectTrigger("@fil") + expect(result).toEqual({ query: "fil", triggerIndex: 0 }) + }) + it("should return null when no @ present", () => { const result = trigger.detectTrigger("hello world") @@ -58,6 +60,11 @@ describe("FileTrigger", () => { expect(result).toBeNull() }) + it("should return null when @ followed by space", () => { + const result = trigger.detectTrigger("@ ") + expect(result).toBeNull() + }) + it("should detect @ trigger even with empty query", () => { const result = trigger.detectTrigger("hello @") @@ -67,6 +74,11 @@ describe("FileTrigger", () => { }) }) + it("should detect @ even without text after it", () => { + const result = trigger.detectTrigger("@") + expect(result).toEqual({ query: "", triggerIndex: 0 }) + }) + it("should find last @ in line", () => { const result = trigger.detectTrigger("email@test.com @file") @@ -95,6 +107,29 @@ describe("FileTrigger", () => { expect(result).toBe("check @/config.json ") }) + + it("should generate correct replacement text for folders", () => { + 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 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) + }) }) describe("search", () => { @@ -194,4 +229,42 @@ describe("FileTrigger", () => { }) }) }) + + describe("renderItem", () => { + const onSearch = vi.fn() + const getResults = (): FileResult[] => [] + const trigger = createFileTrigger({ onSearch, getResults }) + + it("should render file items correctly", () => { + 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 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 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]/) + }) + }) }) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx similarity index 97% rename from apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx rename to apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx index 1d07c7930c..992b3d4919 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx @@ -1,7 +1,6 @@ import { render } from "ink-testing-library" -import { describe, it, expect } from "vitest" -import { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js" +import { createHelpTrigger, type HelpShortcutResult } from "../HelpTrigger.js" describe("HelpTrigger", () => { describe("createHelpTrigger", () => { diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx similarity index 98% rename from apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx rename to apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx index 906280ab42..8e5906ac7c 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx @@ -1,9 +1,7 @@ import { render } from "ink-testing-library" -import { describe, it, expect, vi } from "vitest" -import { createHistoryTrigger, toHistoryResult, type HistoryResult } from "./HistoryTrigger.js" +import { createHistoryTrigger, toHistoryResult, type HistoryResult } from "../HistoryTrigger.js" -// Sample history items for testing const mockHistoryItems: HistoryResult[] = [ { key: "task-1", diff --git a/apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/ModeTrigger.test.tsx similarity index 95% rename from apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts rename to apps/cli/src/ui/components/autocomplete/triggers/__tests__/ModeTrigger.test.tsx index ca928c26b6..ddb4977a69 100644 --- a/apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/ModeTrigger.test.tsx @@ -1,9 +1,4 @@ -import { describe, it, expect } from "vitest" -import { - createModeTrigger, - toModeResult, - type ModeResult, -} from "../../ui/components/autocomplete/triggers/ModeTrigger.js" +import { type ModeResult, createModeTrigger, toModeResult } from "../ModeTrigger.js" describe("ModeTrigger", () => { const testModes: ModeResult[] = [ diff --git a/apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/SlashCommandTrigger.test.tsx similarity index 95% rename from apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts rename to apps/cli/src/ui/components/autocomplete/triggers/__tests__/SlashCommandTrigger.test.tsx index 2319d8babf..74d4407966 100644 --- a/apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/__tests__/SlashCommandTrigger.test.tsx @@ -1,9 +1,4 @@ -import { describe, it, expect, vi } from "vitest" -import { - createSlashCommandTrigger, - toSlashCommandResult, - type SlashCommandResult, -} from "../../ui/components/autocomplete/triggers/SlashCommandTrigger.js" +import { type SlashCommandResult, createSlashCommandTrigger, toSlashCommandResult } from "../SlashCommandTrigger.js" describe("SlashCommandTrigger", () => { describe("toSlashCommandResult", () => { diff --git a/apps/cli/src/ui/components/autocomplete/triggers/index.ts b/apps/cli/src/ui/components/autocomplete/triggers/index.ts index ee8c6c65b0..16d051bdba 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/index.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/index.ts @@ -1,23 +1,19 @@ -/** - * Autocomplete triggers for different trigger patterns. - */ - -export { createFileTrigger, toFileResult, type FileResult, type FileTriggerConfig } from "./FileTrigger.js" +export { type FileResult, type FileTriggerConfig, createFileTrigger, toFileResult } from "./FileTrigger.js" export { - createSlashCommandTrigger, - toSlashCommandResult, type SlashCommandResult, type SlashCommandTriggerConfig, + createSlashCommandTrigger, + toSlashCommandResult, } from "./SlashCommandTrigger.js" -export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js" +export { type ModeResult, type ModeTriggerConfig, createModeTrigger, toModeResult } from "./ModeTrigger.js" -export { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js" +export { type HelpShortcutResult, createHelpTrigger } from "./HelpTrigger.js" export { - createHistoryTrigger, - toHistoryResult, type HistoryResult, type HistoryTriggerConfig, + createHistoryTrigger, + toHistoryResult, } from "./HistoryTrigger.js" diff --git a/apps/cli/src/ui/index.ts b/apps/cli/src/ui/index.ts index 017fd12b38..b4f2849c33 100644 --- a/apps/cli/src/ui/index.ts +++ b/apps/cli/src/ui/index.ts @@ -6,24 +6,8 @@ export { default as Header } from "./components/Header.js" export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js" export { default as LoadingText } from "./components/LoadingText.js" -// Autocomplete system -export { - AutocompleteInput, - PickerSelect, - useAutocompletePicker, - createFileTrigger, - createSlashCommandTrigger, - toFileResult, - toSlashCommandResult, - type AutocompleteInputProps, - type AutocompleteInputHandle, - type AutocompleteItem, - type AutocompleteTrigger, - type AutocompletePickerState, - type PickerSelectProps, - type FileResult, - type SlashCommandResult as AutocompleteSlashCommandResult, -} from "./components/autocomplete/index.js" +// Autocomplete +export * from "./components/autocomplete/index.js" // Hooks export { useInputHistory } from "./hooks/useInputHistory.js" @@ -36,4 +20,4 @@ export { useCLIStore } from "./store.js" export * as theme from "./utils/theme.js" // Types -export type { TUIMessage, PendingAsk, SayType, AskType, AppProps, MessageRole, View } from "./types.js" +export * from "./types.js" diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index f79d5c34cb..c89e3060d3 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -2,14 +2,8 @@ import { create } from "zustand" import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" -import type { - TUIMessage, - PendingAsk, - FileSearchResult, - SlashCommandResult, - ModeResult, - TaskHistoryItem, -} from "./types.js" +import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js" +import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js" /** * RouterModels type for context window lookup. @@ -40,7 +34,7 @@ interface CLIState { isResumingTask: boolean // Autocomplete data (from API/extension) - fileSearchResults: FileSearchResult[] + fileSearchResults: FileResult[] allSlashCommands: SlashCommandResult[] availableModes: ModeResult[] @@ -83,7 +77,7 @@ interface CLIActions { setIsResumingTask: (isResuming: boolean) => void // Autocomplete data actions - setFileSearchResults: (results: FileSearchResult[]) => void + setFileSearchResults: (results: FileResult[]) => void setAllSlashCommands: (commands: SlashCommandResult[]) => void setAvailableModes: (modes: ModeResult[]) => void @@ -210,9 +204,5 @@ export const useCLIStore = create((set) => ({ setTokenUsage: (usage) => set({ tokenUsage: usage }), setRouterModels: (models) => set({ routerModels: models }), setApiConfiguration: (config) => set({ apiConfiguration: config }), - setTodos: (todos) => - set((state) => ({ - previousTodos: state.currentTodos, - currentTodos: todos, - })), + setTodos: (todos) => set((state) => ({ previousTodos: state.currentTodos, currentTodos: todos })), })) diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index d9763febf0..0f715557d6 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -1,45 +1,7 @@ import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types" -import type { GlobalCommandAction } from "../globalCommands.js" - -// Re-export TodoItem for convenience -export type { TodoItem } - export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking" -export type AskType = Extract< - ClineAsk, - | "followup" - | "command" - | "command_output" - | "tool" - | "browser_action_launch" - | "use_mcp_server" - | "api_req_failed" - | "resume_task" - | "resume_completed_task" - | "completion_result" -> - -export type SayType = - | Extract< - ClineSay, - | "text" - | "reasoning" - | "command_output" - | "completion_result" - | "error" - | "api_req_started" - | "user_feedback" - | "checkpoint_saved" - > - | "thinking" - | "tool" - -/** - * Structured tool data for rich rendering - * Extracted from tool JSON payloads for tool-specific layouts - */ export interface ToolData { /** Tool identifier (e.g., "readFile", "appliedDiff", "searchFiles") */ tool: string @@ -130,7 +92,7 @@ export interface TUIMessage { toolDisplayOutput?: string hasPendingToolCalls?: boolean partial?: boolean - originalType?: SayType | AskType + originalType?: ClineAsk | ClineSay /** TODO items for update_todo_list tool messages */ todos?: TodoItem[] /** Previous TODO items for diff display */ @@ -141,7 +103,7 @@ export interface TUIMessage { export interface PendingAsk { id: string - type: AskType + type: ClineAsk content: string suggestions?: Array<{ answer: string; mode?: string | null }> } @@ -159,55 +121,20 @@ export interface AppProps { debug: boolean exitOnComplete: boolean reasoningEffort?: string - /** Run in ephemeral mode - no state persists after this session */ ephemeral?: boolean version: string } export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default" -export interface FileSearchResult { - path: string - type: "file" | "folder" - label?: string -} - -export interface SlashCommandResult { - name: string - description?: string - argumentHint?: string - source: "global" | "project" | "built-in" - /** Action to trigger for CLI global commands (e.g., clearTask for /new) */ - action?: GlobalCommandAction -} - -export interface ModeResult { - slug: string - name: string - description?: string - icon?: string -} - -/** - * Task history item for the CLI. - * Subset of HistoryItem from @roo-code/types with fields needed for display and resumption. - */ export interface TaskHistoryItem { - /** Unique task ID */ id: string - /** Task prompt/description */ task: string - /** Timestamp when task was created */ ts: number - /** Total cost of the task */ totalCost?: number - /** Workspace path where task was run */ workspace?: string - /** Mode the task was run in */ mode?: string - /** Task status */ status?: "active" | "completed" | "delegated" - /** Tokens consumed */ tokensIn?: number tokensOut?: number }