Some cleanup

This commit is contained in:
cte 2026-01-07 23:34:37 -08:00
parent 511586d6bb
commit 245008a43c
18 changed files with 234 additions and 568 deletions

View file

@ -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", () => {

View file

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

View file

@ -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<void>
runTask(prompt: string): Promise<void>
@ -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<string, unknown>
(msg: ExtensionMessage) => {
if (msg.type === "state") {
const state = msg.state as Record<string, unknown>
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<Record<string, unknown>> | 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<string, unknown>
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<string, Record<string, { contextWindow?: number }>> | 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) => (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
</Box>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

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

View file

@ -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[] = [

View file

@ -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", () => {

View file

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

View file

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

View file

@ -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<CLIState & CLIActions>((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 })),
}))

View file

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