From 511586d6bb725588de6749a04f0704e017435f52 Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 7 Jan 2026 22:34:18 -0800 Subject: [PATCH] More progress --- apps/cli/src/ui/App.tsx | 232 +++++++++++++++++- .../cli/src/ui/components/ChatHistoryItem.tsx | 81 +----- apps/cli/src/ui/components/Header.tsx | 11 +- apps/cli/src/ui/components/Icon.tsx | 42 +++- apps/cli/src/ui/components/MetricsDisplay.tsx | 3 +- apps/cli/src/ui/components/TodoDisplay.tsx | 84 +++---- .../__tests__/ChatHistoryItem.test.tsx | 67 ++++- .../components/__tests__/TodoDisplay.test.tsx | 28 ++- .../triggers/HelpTrigger.test.tsx | 25 +- .../autocomplete/triggers/HelpTrigger.tsx | 6 +- .../src/ui/components/tools/BrowserTool.tsx | 91 +++++++ .../src/ui/components/tools/CommandTool.tsx | 49 ++++ .../ui/components/tools/CompletionTool.tsx | 39 +++ .../src/ui/components/tools/FileReadTool.tsx | 135 ++++++++++ .../src/ui/components/tools/FileWriteTool.tsx | 169 +++++++++++++ .../src/ui/components/tools/GenericTool.tsx | 97 ++++++++ apps/cli/src/ui/components/tools/ModeTool.tsx | 86 +++++++ .../src/ui/components/tools/SearchTool.tsx | 117 +++++++++ .../tools/__tests__/CommandTool.test.tsx | 164 +++++++++++++ apps/cli/src/ui/components/tools/index.ts | 63 +++++ apps/cli/src/ui/components/tools/types.ts | 65 +++++ apps/cli/src/ui/components/tools/utils.ts | 226 +++++++++++++++++ apps/cli/src/ui/types.ts | 87 +++++++ apps/cli/src/ui/utils/globalInputSequences.ts | 13 + 24 files changed, 1828 insertions(+), 152 deletions(-) create mode 100644 apps/cli/src/ui/components/tools/BrowserTool.tsx create mode 100644 apps/cli/src/ui/components/tools/CommandTool.tsx create mode 100644 apps/cli/src/ui/components/tools/CompletionTool.tsx create mode 100644 apps/cli/src/ui/components/tools/FileReadTool.tsx create mode 100644 apps/cli/src/ui/components/tools/FileWriteTool.tsx create mode 100644 apps/cli/src/ui/components/tools/GenericTool.tsx create mode 100644 apps/cli/src/ui/components/tools/ModeTool.tsx create mode 100644 apps/cli/src/ui/components/tools/SearchTool.tsx create mode 100644 apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx create mode 100644 apps/cli/src/ui/components/tools/index.ts create mode 100644 apps/cli/src/ui/components/tools/types.ts create mode 100644 apps/cli/src/ui/components/tools/utils.ts diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index 38fc6b23a1..7ad39b376b 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -16,6 +16,7 @@ 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 { AutocompleteInput, @@ -54,6 +55,7 @@ import type { SlashCommandResult, ModeResult, TaskHistoryItem, + ToolData, } from "./types.js" import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js" @@ -243,6 +245,9 @@ function AppInner({ const seenMessageIds = useRef>(new Set()) const firstTextMessageSkipped = useRef(false) + // Track pending command for injecting into command_output toolData + const pendingCommandRef = useRef(null) + // Track Ctrl+C presses for "press again to exit" behavior const [showExitHint, setShowExitHint] = useState(false) const exitHintTimeout = useRef(null) @@ -260,6 +265,9 @@ function AppInner({ // Manual focus override: 'scroll' | 'input' | null (null = auto-determine) const [manualFocus, setManualFocus] = useState<"scroll" | "input" | null>(null) + // State for TODO list viewer (shown via Ctrl+T shortcut) + const [showTodoViewer, setShowTodoViewer] = useState(false) + // Autocomplete picker state (received from AutocompleteInput via callback) // eslint-disable-next-line @typescript-eslint/no-explicit-any const [pickerState, setPickerState] = useState>({ @@ -430,7 +438,32 @@ function AppInner({ return } + // Ctrl+T to toggle TODO list viewer + if (matchesGlobalSequence(input, key, "ctrl-t")) { + // Close picker if open + if (pickerState.isOpen) { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } + // Toggle TODO viewer + setShowTodoViewer((prev) => { + const newValue = !prev + if (newValue && currentTodos.length === 0) { + showInfo("No TODO list available", 2000) + return false + } + return newValue + }) + return + } + // Escape key to cancel/pause task when loading (streaming) + // Escape key to close TODO viewer + if (key.escape && showTodoViewer) { + setShowTodoViewer(false) + return + } + if (key.escape && isLoading && hostRef.current) { // If picker is open, let the picker handle escape first if (pickerState.isOpen) { @@ -599,12 +632,23 @@ function AppInner({ let toolName: string | undefined let toolDisplayName: string | undefined let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined if (say === "command_output") { role = "tool" 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 + pendingCommandRef.current = null } else if (say === "tool") { role = "tool" try { @@ -621,6 +665,8 @@ function AppInner({ toolName = toolInfo.tool toolDisplayName = toolInfo.tool toolDisplayOutput = formatToolOutput(toolInfo) + // Extract structured toolData for rich rendering + toolData = extractToolData(toolInfo) // Special handling for update_todo_list tool if (toolName === "update_todo_list" || toolName === "updateTodoList") { @@ -643,6 +689,7 @@ function AppInner({ originalType: say, todos, previousTodos: prevTodos, + toolData, }) return } @@ -665,6 +712,7 @@ function AppInner({ toolDisplayOutput, partial, originalType: say, + toolData, }) }, [addMessage, verbose, currentTodos, setTodos], @@ -707,9 +755,52 @@ function AppInner({ seenMessageIds.current.add(messageId) setComplete(true) setLoading(false) + + // Parse the completion result and add a message for CompletionTool to render + try { + const completionInfo = JSON.parse(text) as Record + const toolData: ToolData = { + tool: "attempt_completion", + result: completionInfo.result as string | undefined, + content: completionInfo.result as string | undefined, + } + + addMessage({ + id: messageId, + role: "tool", + content: text, + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), + originalType: ask, + toolData, + }) + } catch { + // If parsing fails, still add a basic completion message + addMessage({ + id: messageId, + role: "tool", + content: text || "Task completed", + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: "โœ… Task completed", + originalType: ask, + toolData: { + tool: "attempt_completion", + content: text, + }, + }) + } return } + // Track pending command BEFORE nonInteractive handling + // This ensures we capture the command text for later injection into command_output toolData + if (ask === "command") { + toolInspectorLog("ask:command:tracking", { ts, text }) + pendingCommandRef.current = text + } + if (nonInteractive && ask !== "followup") { seenMessageIds.current.add(messageId) @@ -718,6 +809,9 @@ function AppInner({ let toolDisplayName: string | undefined let toolDisplayOutput: string | undefined let formattedContent = text || "" + let toolData: ToolData | undefined + let todos: TodoItem[] | undefined + let previousTodos: TodoItem[] | undefined try { const toolInfo = JSON.parse(text) as Record @@ -734,6 +828,19 @@ function AppInner({ toolDisplayName = toolInfo.tool as string toolDisplayOutput = formatToolOutput(toolInfo) formattedContent = formatToolAskMessage(toolInfo) + // Extract structured toolData for rich rendering + toolData = extractToolData(toolInfo) + + // Special handling for update_todo_list tool - extract todos + if (toolName === "update_todo_list" || toolName === "updateTodoList") { + const parsedTodos = parseTodosFromToolInfo(toolInfo) + if (parsedTodos && parsedTodos.length > 0) { + todos = parsedTodos + // Capture previous todos before updating global state + previousTodos = [...currentTodos] + setTodos(parsedTodos) + } + } } catch { // Use raw text if not valid JSON } @@ -746,6 +853,9 @@ function AppInner({ toolDisplayName, toolDisplayOutput, originalType: ask, + toolData, + todos, + previousTodos, }) } else { addMessage({ @@ -786,6 +896,7 @@ function AppInner({ // Use raw text if not valid JSON } } + // Note: ask === "command" is handled above before the nonInteractive block seenMessageIds.current.add(messageId) @@ -796,7 +907,7 @@ function AppInner({ suggestions, }) }, - [addMessage, setPendingAsk, setComplete, setLoading, nonInteractive], + [addMessage, setPendingAsk, setComplete, setLoading, nonInteractive, currentTodos, setTodos], ) // Handle extension messages @@ -1282,7 +1393,7 @@ function AppInner({ ) : isScrollAreaActive ? ( ) : isInputAreaActive ? ( - ? for shortcuts โ€ข Ctrl+M mode + ? for shortcuts ) : null // Get render function for picker items based on active trigger @@ -1428,7 +1539,14 @@ function AppInner({ prompt="โ€บ " /> - {pickerState.isOpen ? ( + {showTodoViewer ? ( + + + + Ctrl+T to close + + + ) : pickerState.isOpen ? ( ): ToolData { + const toolName = (toolInfo.tool as string) || "unknown" + + // Base tool data with common fields + const toolData: ToolData = { + tool: toolName, + path: toolInfo.path as string | undefined, + isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined, + isProtected: toolInfo.isProtected as boolean | undefined, + content: toolInfo.content as string | undefined, + reason: toolInfo.reason as string | undefined, + } + + // Extract diff-related fields + if (toolInfo.diff !== undefined) { + toolData.diff = toolInfo.diff as string + } + if (toolInfo.diffStats !== undefined) { + const stats = toolInfo.diffStats as { added?: number; removed?: number } + if (typeof stats.added === "number" && typeof stats.removed === "number") { + toolData.diffStats = { added: stats.added, removed: stats.removed } + } + } + + // Extract search-related fields + if (toolInfo.regex !== undefined) { + toolData.regex = toolInfo.regex as string + } + if (toolInfo.filePattern !== undefined) { + toolData.filePattern = toolInfo.filePattern as string + } + if (toolInfo.query !== undefined) { + toolData.query = toolInfo.query as string + } + + // Extract mode-related fields + if (toolInfo.mode !== undefined) { + toolData.mode = toolInfo.mode as string + } + if (toolInfo.mode_slug !== undefined) { + toolData.mode = toolInfo.mode_slug as string + } + + // Extract command-related fields + if (toolInfo.command !== undefined) { + toolData.command = toolInfo.command as string + } + if (toolInfo.output !== undefined) { + toolData.output = toolInfo.output as string + } + + // Extract browser-related fields + if (toolInfo.action !== undefined) { + toolData.action = toolInfo.action as string + } + if (toolInfo.url !== undefined) { + toolData.url = toolInfo.url as string + } + if (toolInfo.coordinate !== undefined) { + toolData.coordinate = toolInfo.coordinate as string + } + + // Extract batch file operations + if (Array.isArray(toolInfo.files)) { + toolData.batchFiles = (toolInfo.files as Array>).map((f) => ({ + path: (f.path as string) || "", + lineSnippet: f.lineSnippet as string | undefined, + isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined, + key: f.key as string | undefined, + content: f.content as string | undefined, + })) + } + + // Extract batch diff operations + if (Array.isArray(toolInfo.batchDiffs)) { + toolData.batchDiffs = (toolInfo.batchDiffs as Array>).map((d) => ({ + path: (d.path as string) || "", + changeCount: d.changeCount as number | undefined, + key: d.key as string | undefined, + content: d.content as string | undefined, + diffStats: d.diffStats as { added: number; removed: number } | undefined, + diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined, + })) + } + + // Extract question/completion fields + if (toolInfo.question !== undefined) { + toolData.question = toolInfo.question as string + } + if (toolInfo.result !== undefined) { + toolData.result = toolInfo.result as string + } + + // Extract additional display hints + if (toolInfo.lineNumber !== undefined) { + toolData.lineNumber = toolInfo.lineNumber as number + } + if (toolInfo.additionalFileCount !== undefined) { + toolData.additionalFileCount = toolInfo.additionalFileCount as number + } + + return toolData +} + /** * Format tool output for display (used in the message body, header shows tool name separately) */ diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 947756f29b..364304ebc6 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -4,65 +4,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" - -/** - * Default icon for unknown tools - */ -const DEFAULT_TOOL_ICON = "๐Ÿ”ง" - -/** - * Tool icons for visual identification - */ -const TOOL_ICONS: Record = { - // File operations - readFile: "๐Ÿ“„", - read_file: "๐Ÿ“„", - writeToFile: "๐Ÿ“", - write_to_file: "๐Ÿ“", - applyDiff: "โœ๏ธ", - apply_diff: "โœ๏ธ", - - // Directory operations - listFiles: "๐Ÿ“", - list_files: "๐Ÿ“", - listFilesRecursive: "๐Ÿ“‚", - listFilesTopLevel: "๐Ÿ“", - - // Search - searchFiles: "๐Ÿ”", - search_files: "๐Ÿ”", - - // Commands - executeCommand: "๐Ÿ’ป", - execute_command: "๐Ÿ’ป", - - // Browser - browserAction: "๐ŸŒ", - browser_action: "๐ŸŒ", - - // Mode/Task - switchMode: "๐Ÿ”€", - switch_mode: "๐Ÿ”€", - newTask: "๐Ÿ“‹", - new_task: "๐Ÿ“‹", - - // Questions/Completion - askFollowupQuestion: "โ“", - ask_followup_question: "โ“", - attemptCompletion: "โœ…", - attempt_completion: "โœ…", - - // TODO - updateTodoList: "โ˜‘๏ธ", - update_todo_list: "โ˜‘๏ธ", -} - -/** - * Get the icon for a tool - */ -function getToolIcon(toolName: string): string { - return TOOL_ICONS[toolName] ?? DEFAULT_TOOL_ICON -} +import { getToolRenderer } from "./tools/index.js" /** * Tool categories for styling @@ -145,7 +87,6 @@ function parseToolInfo(content: string): Record | null { */ function ToolDisplay({ message }: { message: TUIMessage }) { const toolName = message.toolName || "unknown" - const icon = getToolIcon(toolName) const category = getToolCategory(toolName) const categoryColor = CATEGORY_COLORS[category] @@ -165,8 +106,7 @@ function ToolDisplay({ message }: { message: TUIMessage }) { const sanitizedRawContent = rawContent ? sanitizeContent(rawContent) : undefined // Format the header - const displayName = message.toolDisplayName || toolName - const headerText = `${icon} ${displayName}` + const headerText = message.toolDisplayName || toolName return ( @@ -280,17 +220,16 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { message.todos && message.todos.length > 0 ) { - return ( - - - - - - - ) + return } - // Use the improved ToolDisplay component + // Use the new structured tool renderers when toolData is available + if (message.toolData) { + const ToolRenderer = getToolRenderer(message.toolData.tool) + return + } + + // Fallback to generic ToolDisplay for messages without toolData return } case "system": diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index e200544934..15e24e0a91 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -51,15 +51,14 @@ function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contex Mode: {mode} Model: {model} Reasoning: {reasoningEffort} - {showMetrics && ( - - - - )} - {/* Inline horizontal line using the same columns value */} + {showMetrics && ( + + + + )} {"โ”€".repeat(columns)} ) diff --git a/apps/cli/src/ui/components/Icon.tsx b/apps/cli/src/ui/components/Icon.tsx index 5a030f543d..ce9d14c68e 100644 --- a/apps/cli/src/ui/components/Icon.tsx +++ b/apps/cli/src/ui/components/Icon.tsx @@ -5,7 +5,28 @@ 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" +export type IconName = + | "folder" + | "file" + | "file-edit" + | "check" + | "cross" + | "arrow-right" + | "bullet" + | "spinner" + // Tool-related icons + | "search" + | "terminal" + | "browser" + | "switch" + | "question" + | "gear" + | "diff" + // TODO-related icons + | "checkbox" + | "checkbox-checked" + | "checkbox-progress" + | "todo-list" /** * Icon definitions with Nerd Font glyph and ASCII fallback. @@ -14,11 +35,25 @@ export type IconName = "folder" | "file" | "check" | "cross" | "arrow-right" | " const ICONS: Record = { folder: { nerd: "\udb80\ude4b", fallback: "โ–ผ" }, file: { nerd: "\udb80\ude14", fallback: "โ—" }, + "file-edit": { nerd: "\uf040", fallback: "โœŽ" }, check: { nerd: "\uf00c", fallback: "โœ“" }, cross: { nerd: "\uf00d", fallback: "โœ—" }, "arrow-right": { nerd: "\uf061", fallback: "โ†’" }, bullet: { nerd: "\uf111", fallback: "โ€ข" }, spinner: { nerd: "\uf110", fallback: "*" }, + // Tool-related icons + search: { nerd: "\uf002", fallback: "๐Ÿ”" }, + terminal: { nerd: "\uf120", fallback: "$" }, + browser: { nerd: "\uf0ac", fallback: "๐ŸŒ" }, + switch: { nerd: "\uf074", fallback: "โ‡„" }, + question: { nerd: "\uf128", fallback: "?" }, + gear: { nerd: "\uf013", fallback: "โš™" }, + diff: { nerd: "\uf46d", fallback: "ยฑ" }, + // TODO-related icons + checkbox: { nerd: "\uf096", fallback: "โ—‹" }, // Empty checkbox + "checkbox-checked": { nerd: "\uf14a", fallback: "โœ“" }, // Checked checkbox + "checkbox-progress": { nerd: "\uf192", fallback: "โ†’" }, // In progress (dot circle) + "todo-list": { nerd: "\uf0cb", fallback: "โ˜‘" }, // List icon for TODO header } /** @@ -105,11 +140,6 @@ export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: Icon 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) diff --git a/apps/cli/src/ui/components/MetricsDisplay.tsx b/apps/cli/src/ui/components/MetricsDisplay.tsx index eed840b612..d999b21efc 100644 --- a/apps/cli/src/ui/components/MetricsDisplay.tsx +++ b/apps/cli/src/ui/components/MetricsDisplay.tsx @@ -42,7 +42,7 @@ function formatCost(cost: number): string { /** * Displays task metrics in a compact format: - * $0.12 โ”‚ โ†“45.2K โ”‚ โ†‘8.7K โ”‚ Context: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘] 62% + * $0.12 โ”‚ โ†“45.2K โ”‚ โ†‘8.7K โ”‚ [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘] 62% */ function MetricsDisplay({ tokenUsage, contextWindow }: MetricsDisplayProps) { const { totalCost, totalTokensIn, totalTokensOut, contextTokens } = tokenUsage @@ -59,7 +59,6 @@ function MetricsDisplay({ tokenUsage, contextWindow }: MetricsDisplayProps) { โ†‘ {formatNumber(totalTokensOut)} โ€ข - Context: ) diff --git a/apps/cli/src/ui/components/TodoDisplay.tsx b/apps/cli/src/ui/components/TodoDisplay.tsx index 6c8567d308..9eba600afb 100644 --- a/apps/cli/src/ui/components/TodoDisplay.tsx +++ b/apps/cli/src/ui/components/TodoDisplay.tsx @@ -5,15 +5,16 @@ import type { TodoItem } from "@roo-code/types" import * as theme from "../utils/theme.js" import ProgressBar from "./ProgressBar.js" +import { Icon, type IconName } from "./Icon.js" /** - * Status icons for TODO items using Unicode characters + * Map TODO status to Icon names */ -const STATUS_ICONS = { - completed: "โœ“", - in_progress: "โ†’", - pending: "โ—‹", -} as const +const STATUS_ICON_NAMES: Record = { + completed: "checkbox-checked", + in_progress: "checkbox-progress", + pending: "checkbox", +} /** * Get the color for a TODO status @@ -39,7 +40,7 @@ interface TodoDisplayProps { showProgress?: boolean /** Whether to show only changed items (default: false) */ showChangesOnly?: boolean - /** Title to display in the header (default: "TODO List Updated") */ + /** Title to display in the header (default: "Progress") */ title?: string } @@ -47,21 +48,20 @@ interface TodoDisplayProps { * TodoDisplay component for CLI * * Renders a beautiful TODO list visualization with: - * - Status icons (โœ“ completed, โ†’ in progress, โ—‹ pending) - * - Color-coded items based on status + * - Nerd Font icons (or ASCII fallbacks) for status + * - Color-coded items based on status (green/yellow/gray) * - Progress bar showing completion percentage * - Optional diff mode showing only changed items + * - Change indicators ([done], [started], [new]) * - * Visual example: + * Visual example (with fallback icons): * ``` - * โ”Œโ”€ TODO List Updated โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - * โ”‚ โœ“ Analyze requirements โ”‚ - * โ”‚ โœ“ Design architecture โ”‚ - * โ”‚ โ†’ Implement core logic โ”‚ - * โ”‚ โ—‹ Write tests โ”‚ - * โ”‚ โ—‹ Update documentation โ”‚ - * โ”‚ [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 2/5 completed โ”‚ - * โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + * โ˜‘ Progress [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 2/5 + * โœ“ Analyze requirements [done] + * โœ“ Design architecture [done] + * โ†’ Implement core logic + * โ—‹ Write tests + * โ—‹ Update documentation [new] * ``` */ function TodoDisplay({ @@ -69,7 +69,7 @@ function TodoDisplay({ previousTodos = [], showProgress = true, showChangesOnly = false, - title = "TODO List Updated", + title = "Progress", }: TodoDisplayProps) { if (!todos || todos.length === 0) { return null @@ -101,26 +101,28 @@ function TodoDisplay({ // Calculate progress statistics const totalCount = todos.length const completedCount = todos.filter((t) => t.status === "completed").length - const inProgressCount = todos.filter((t) => t.status === "in_progress").length return ( - - {/* Header */} + + {/* Header with progress bar on same line */} + - โ˜‘ {title} + {" "} + {title} - - - {/* Border top */} - - {"โ”€".repeat(50)} + {showProgress && ( + <> + + + + )} {/* TODO items */} - + {displayTodos.map((todo, index) => { - const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.pending + const iconName = STATUS_ICON_NAMES[todo.status] || STATUS_ICON_NAMES.pending const color = getStatusColor(todo.status) // Check if this item changed status @@ -130,9 +132,8 @@ function TodoDisplay({ return ( - - {icon} {todo.content} - + + {todo.content} {statusChanged && ( {" "} @@ -155,23 +156,6 @@ function TodoDisplay({ ) })} - - {/* Progress bar and stats */} - {showProgress && ( - - - {"โ”€".repeat(50)} - - - - - {" "} - {completedCount}/{totalCount} completed - {inProgressCount > 0 && `, ${inProgressCount} in progress`} - - - - )} ) } diff --git a/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx index 0d50dd6408..c509273452 100644 --- a/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx +++ b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx @@ -2,8 +2,20 @@ import { render } from "ink-testing-library" import type { TUIMessage } from "../../types.js" import ChatHistoryItem from "../ChatHistoryItem.js" +import { resetNerdFontCache } from "../Icon.js" describe("ChatHistoryItem", () => { + beforeEach(() => { + // Use fallback icons in tests so they render as visible characters + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + }) + + afterEach(() => { + delete process.env.ROOCODE_NERD_FONT + resetNerdFontCache() + }) + describe("content sanitization", () => { it("sanitizes tabs in user messages", () => { const message: TUIMessage = { @@ -208,8 +220,8 @@ describe("ChatHistoryItem", () => { const { lastFrame } = render() const output = lastFrame() - // New format uses icon + display name - expect(output).toContain("๐Ÿ“„ Read File") + // ToolDisplay (fallback without toolData) shows display name without icon + expect(output).toContain("Read File") expect(output).toContain("Output text") }) @@ -303,7 +315,8 @@ describe("ChatHistoryItem", () => { const { lastFrame } = render() const output = lastFrame() - expect(output).toContain("๐Ÿ’ป Execute Command") + // ToolDisplay (fallback without toolData) shows display name without icon + expect(output).toContain("Execute Command") expect(output).toContain("command output") }) @@ -320,7 +333,53 @@ describe("ChatHistoryItem", () => { const { lastFrame } = render() const output = lastFrame() - expect(output).toContain("๐Ÿ” Search Files") + // ToolDisplay (fallback without toolData) shows display name without icon + expect(output).toContain("Search Files") + }) + + it("renders attempt_completion tool with CompletionTool renderer", () => { + const message: TUIMessage = { + id: "12", + role: "tool", + content: JSON.stringify({ + tool: "attempt_completion", + result: "I've completed the task successfully.", + }), + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: "โœ… I've completed the task successfully.", + toolData: { + tool: "attempt_completion", + result: "I've completed the task successfully.", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // CompletionTool renders the result content directly without icon or header + expect(output).toContain("I've completed the task successfully.") + }) + + it("renders ask_followup_question tool with CompletionTool renderer", () => { + const message: TUIMessage = { + id: "13", + role: "tool", + content: JSON.stringify({ tool: "ask_followup_question", question: "What color would you like?" }), + toolName: "ask_followup_question", + toolDisplayName: "Question", + toolDisplayOutput: "โ“ What color would you like?", + toolData: { + tool: "ask_followup_question", + question: "What color would you like?", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // CompletionTool renders the question content directly without icon or header + expect(output).toContain("What color would you like?") }) }) }) diff --git a/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx b/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx index e877fcba72..f48bfe8401 100644 --- a/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx +++ b/apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx @@ -3,8 +3,20 @@ import { render } from "ink-testing-library" import type { TodoItem } from "@roo-code/types" import TodoDisplay from "../TodoDisplay.js" +import { resetNerdFontCache } from "../Icon.js" describe("TodoDisplay", () => { + beforeEach(() => { + // Use fallback icons in tests so they render as visible characters + process.env.ROOCODE_NERD_FONT = "0" + resetNerdFontCache() + }) + + afterEach(() => { + delete process.env.ROOCODE_NERD_FONT + resetNerdFontCache() + }) + const mockTodos: TodoItem[] = [ { id: "1", content: "Analyze requirements", status: "completed" }, { id: "2", content: "Design architecture", status: "completed" }, @@ -17,8 +29,8 @@ describe("TodoDisplay", () => { const { lastFrame } = render() const output = lastFrame() - // Check header - expect(output).toContain("TODO List Updated") + // Check header (default title is "Progress") + expect(output).toContain("Progress") // Check all items are rendered expect(output).toContain("Analyze requirements") @@ -27,7 +39,7 @@ describe("TodoDisplay", () => { expect(output).toContain("Write tests") expect(output).toContain("Update documentation") - // Check status icons are present + // Check status icons are present (fallback icons) expect(output).toContain("โœ“") // completed expect(output).toContain("โ†’") // in_progress expect(output).toContain("โ—‹") // pending @@ -37,8 +49,8 @@ describe("TodoDisplay", () => { const { lastFrame } = render() const output = lastFrame() - // Check progress stats - expect(output).toContain("2/5 completed") + // Check progress bar shows percentage (2/5 = 40%) + expect(output).toContain("40%") }) it("hides progress bar when showProgress is false", () => { @@ -132,7 +144,9 @@ describe("TodoDisplay", () => { const { lastFrame } = render() const output = lastFrame() - expect(output).toContain("1/4 completed") - expect(output).toContain("2 in progress") + // Progress bar shows percentage (1/4 = 25%) + expect(output).toContain("25%") + // In_progress items render with the arrow icon + expect(output).toContain("โ†’") // in_progress indicator }) }) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx index b8c8ee0b02..1d07c7930c 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.test.tsx @@ -45,13 +45,36 @@ describe("HelpTrigger", () => { const trigger = createHelpTrigger() const results = trigger.search("") as HelpShortcutResult[] - expect(results.length).toBe(6) + expect(results.length).toBe(8) expect(results.map((r) => r.shortcut)).toContain("/") expect(results.map((r) => r.shortcut)).toContain("@") expect(results.map((r) => r.shortcut)).toContain("!") expect(results.map((r) => r.shortcut)).toContain("shift + โŽ") expect(results.map((r) => r.shortcut)).toContain("tab") + expect(results.map((r) => r.shortcut)).toContain("ctrl + m") expect(results.map((r) => r.shortcut)).toContain("ctrl + c") + expect(results.map((r) => r.shortcut)).toContain("ctrl + t") + }) + + it("should include ctrl+t shortcut for TODO list", () => { + const trigger = createHelpTrigger() + + const results = trigger.search("todo") as HelpShortcutResult[] + expect(results.length).toBe(1) + expect(results[0]?.shortcut).toBe("ctrl + t") + expect(results[0]?.description).toContain("TODO") + }) + + it("should clear input for todos action shortcut", () => { + const trigger = createHelpTrigger() + + const todosItem: HelpShortcutResult = { + key: "todos", + shortcut: "ctrl + t", + description: "to view TODO list", + } + const replacement = trigger.getReplacementText(todosItem, "?todo", 0) + expect(replacement).toBe("") }) it("should filter shortcuts by shortcut character", () => { diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx index c34aea7951..c029bc8031 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx +++ b/apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx @@ -22,6 +22,8 @@ const HELP_SHORTCUTS: HelpShortcutResult[] = [ { key: "bang", shortcut: "!", description: "for modes" }, { key: "newline", shortcut: "shift + โŽ", description: "for newline" }, { key: "focus", shortcut: "tab", description: "to toggle focus" }, + { key: "mode", shortcut: "ctrl + m", description: "to cycle modes" }, + { key: "todos", shortcut: "ctrl + t", description: "to view TODO list" }, { key: "quit", shortcut: "ctrl + c", description: "to quit" }, ] @@ -92,8 +94,8 @@ export function createHelpTrigger(): AutocompleteTrigger { getReplacementText: (item: HelpShortcutResult, _lineText: string, _triggerIndex: number): string => { // When a shortcut is selected, replace with the trigger character - // For action shortcuts (tab, ctrl+c, shift+enter), just clear the input - if (["newline", "focus", "quit"].includes(item.key)) { + // For action shortcuts (tab, ctrl+c, shift+enter, ctrl+t), just clear the input + if (["newline", "focus", "quit", "todos"].includes(item.key)) { return "" } // For trigger shortcuts (/, @, !), insert the trigger character diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx new file mode 100644 index 0000000000..311d8f6fe2 --- /dev/null +++ b/apps/cli/src/ui/components/tools/BrowserTool.tsx @@ -0,0 +1,91 @@ +/** + * Renderer for browser actions + * Handles: browser_action + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { getToolDisplayName, getToolIconName } from "./utils.js" + +const ACTION_LABELS: Record = { + launch: "Launch Browser", + click: "Click", + hover: "Hover", + type: "Type Text", + press: "Press Key", + scroll_down: "Scroll Down", + scroll_up: "Scroll Up", + resize: "Resize Window", + close: "Close Browser", + screenshot: "Take Screenshot", +} + +export function BrowserTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const action = toolData.action || "" + const url = toolData.url || "" + const coordinate = toolData.coordinate || "" + const content = toolData.content || "" // May contain text for type action + + const actionLabel = ACTION_LABELS[action] || action + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + {action && ( + + {" "} + โ†’ {actionLabel} + + )} + + + {/* Action details */} + + {/* URL for launch action */} + {url && ( + + url: + + {url} + + + )} + + {/* Coordinates for click/hover actions */} + {coordinate && ( + + at: + {coordinate} + + )} + + {/* Text content for type action */} + {content && action === "type" && ( + + text: + "{content}" + + )} + + {/* Key for press action */} + {content && action === "press" && ( + + key: + {content} + + )} + + + ) +} diff --git a/apps/cli/src/ui/components/tools/CommandTool.tsx b/apps/cli/src/ui/components/tools/CommandTool.tsx new file mode 100644 index 0000000000..52c67a93e4 --- /dev/null +++ b/apps/cli/src/ui/components/tools/CommandTool.tsx @@ -0,0 +1,49 @@ +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolIconName } from "./utils.js" + +const MAX_OUTPUT_LINES = 10 + +export function CommandTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const command = toolData.command || "" + const output = toolData.output ? sanitizeContent(toolData.output) : "" + const content = toolData.content ? sanitizeContent(toolData.content) : "" + const displayOutput = output || content + const { text: previewOutput, truncated, hiddenLines } = truncateText(displayOutput, MAX_OUTPUT_LINES) + + return ( + + + + {command && ( + + $ + + {command} + + + )} + + {previewOutput && ( + + + {previewOutput.split("\n").map((line, i) => ( + + {line} + + ))} + + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/CompletionTool.tsx b/apps/cli/src/ui/components/tools/CompletionTool.tsx new file mode 100644 index 0000000000..b1d0728097 --- /dev/null +++ b/apps/cli/src/ui/components/tools/CompletionTool.tsx @@ -0,0 +1,39 @@ +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent } from "./utils.js" + +const MAX_CONTENT_LINES = 15 + +export function CompletionTool({ toolData }: ToolRendererProps) { + const result = toolData.result ? sanitizeContent(toolData.result) : "" + const question = toolData.question ? sanitizeContent(toolData.question) : "" + const content = toolData.content ? sanitizeContent(toolData.content) : "" + const isQuestion = toolData.tool.includes("question") || toolData.tool.includes("Question") + const displayContent = result || question || content + const { text: previewContent, truncated, hiddenLines } = truncateText(displayContent, MAX_CONTENT_LINES) + + return previewContent ? ( + + {isQuestion ? ( + + {previewContent} + + ) : ( + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + + )} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + ) : null +} diff --git a/apps/cli/src/ui/components/tools/FileReadTool.tsx b/apps/cli/src/ui/components/tools/FileReadTool.tsx new file mode 100644 index 0000000000..4cfad5722a --- /dev/null +++ b/apps/cli/src/ui/components/tools/FileReadTool.tsx @@ -0,0 +1,135 @@ +/** + * Renderer for file read operations + * Handles: readFile, fetchInstructions, listFilesTopLevel, listFilesRecursive + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_PREVIEW_LINES = 12 + +/** + * Check if content looks like actual file content vs just path info + * File content typically has newlines or is longer than a typical path + */ +function isActualContent(content: string, path: string): boolean { + if (!content) return false + // If content equals path or is just the path, it's not actual content + if (content === path || content.endsWith(path)) return false + // Check if it looks like a plain path (no newlines, starts with / or drive letter) + if (!content.includes("\n") && (content.startsWith("/") || /^[A-Z]:\\/.test(content))) return false + // Has newlines or doesn't look like a path - treat as content + return content.includes("\n") || content.length > 200 +} + +export function FileReadTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const path = toolData.path || "" + const rawContent = toolData.content ? sanitizeContent(toolData.content) : "" + const isOutsideWorkspace = toolData.isOutsideWorkspace + const isList = toolData.tool.includes("list") || toolData.tool.includes("List") + + // Only show content if it's actual file content, not just path info + const content = isActualContent(rawContent, path) ? rawContent : "" + + // Handle batch file reads + if (toolData.batchFiles && toolData.batchFiles.length > 0) { + return ( + + {/* Header */} + + + + {" "} + {displayName} + + ({toolData.batchFiles.length} files) + + + {/* File list */} + + {toolData.batchFiles.slice(0, 10).map((file, index) => ( + + + {file.path} + + {file.lineSnippet && ({file.lineSnippet})} + {file.isOutsideWorkspace && ( + + {" "} + โš  outside workspace + + )} + + ))} + {toolData.batchFiles.length > 10 && ( + ... and {toolData.batchFiles.length - 10} more files + )} + + + ) + } + + // Single file read + const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_PREVIEW_LINES) + + return ( + + {/* Header with path on same line for single file */} + + + + {displayName} + + {path && ( + <> + ยท + + {path} + + {isOutsideWorkspace && ( + + {" "} + โš  outside workspace + + )} + + )} + + + {/* Content preview - only if we have actual file content */} + {previewContent && ( + + {isList ? ( + // Directory listing - show as tree-like structure + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + + ) : ( + // File content - show in a box + + + {previewContent} + + + )} + + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/FileWriteTool.tsx b/apps/cli/src/ui/components/tools/FileWriteTool.tsx new file mode 100644 index 0000000000..531f5feb44 --- /dev/null +++ b/apps/cli/src/ui/components/tools/FileWriteTool.tsx @@ -0,0 +1,169 @@ +/** + * Renderer for file write operations + * Handles: editedExistingFile, appliedDiff, newFileCreated, write_to_file + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js" + +const MAX_DIFF_LINES = 15 + +export function FileWriteTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const path = toolData.path || "" + const diffStats = toolData.diffStats + const diff = toolData.diff ? sanitizeContent(toolData.diff) : "" + const isProtected = toolData.isProtected + const isOutsideWorkspace = toolData.isOutsideWorkspace + const isNewFile = toolData.tool === "newFileCreated" || toolData.tool === "write_to_file" + + // Handle batch diff operations + if (toolData.batchDiffs && toolData.batchDiffs.length > 0) { + return ( + + {/* Header */} + + + + {" "} + {displayName} + + ({toolData.batchDiffs.length} files) + + + {/* File list with stats */} + + {toolData.batchDiffs.slice(0, 8).map((file, index) => ( + + + {file.path} + + {file.diffStats && ( + + +{file.diffStats.added} + / + -{file.diffStats.removed} + + )} + + ))} + {toolData.batchDiffs.length > 8 && ( + ... and {toolData.batchDiffs.length - 8} more files + )} + + + ) + } + + // Single file write + const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES) + const diffHunks = diff ? parseDiff(diff) : [] + + return ( + + {/* Header row with path on same line */} + + + + {displayName} + + {path && ( + <> + ยท + + {path} + + + )} + {isNewFile && ( + + {" "} + NEW + + )} + + {/* Diff stats badge */} + {diffStats && ( + <> + + + +{diffStats.added} + + / + + -{diffStats.removed} + + + )} + + {/* Warning badges */} + {isProtected && ๐Ÿ”’ protected} + {isOutsideWorkspace && ( + + {" "} + โš  outside workspace + + )} + + + {/* Diff preview */} + {diffHunks.length > 0 && ( + + {diffHunks.slice(0, 2).map((hunk, hunkIndex) => ( + + {/* Hunk header */} + + {hunk.header} + + + {/* Diff lines */} + {hunk.lines.slice(0, 8).map((line, lineIndex) => ( + + {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "} + {line.content} + + ))} + + {hunk.lines.length > 8 && ( + + ... ({hunk.lines.length - 8} more lines in hunk) + + )} + + ))} + + {diffHunks.length > 2 && ( + + ... ({diffHunks.length - 2} more hunks) + + )} + + )} + + {/* Fallback to raw diff if no hunks parsed */} + {diffHunks.length === 0 && previewDiff && ( + + {previewDiff} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/GenericTool.tsx b/apps/cli/src/ui/components/tools/GenericTool.tsx new file mode 100644 index 0000000000..0058c2e072 --- /dev/null +++ b/apps/cli/src/ui/components/tools/GenericTool.tsx @@ -0,0 +1,97 @@ +/** + * Generic fallback renderer for unknown tools + * Used when no specific renderer exists for a tool type + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_CONTENT_LINES = 12 + +export function GenericTool({ toolData, rawContent }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + + // Gather all available information + const path = toolData.path + const content = toolData.content ? sanitizeContent(toolData.content) : "" + const reason = toolData.reason ? sanitizeContent(toolData.reason) : "" + const mode = toolData.mode + + // Build display content from available fields + let displayContent = content || reason || "" + + // If we have no structured content but have raw content, try to parse it + if (!displayContent && rawContent) { + try { + const parsed = JSON.parse(rawContent) + // Extract any content-like fields + displayContent = sanitizeContent(parsed.content || parsed.output || parsed.result || parsed.reason || "") + } catch { + // Use raw content as-is if not JSON + displayContent = sanitizeContent(rawContent) + } + } + + const { text: previewContent, truncated, hiddenLines } = truncateText(displayContent, MAX_CONTENT_LINES) + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + + + {/* Path if present */} + {path && ( + + path: + + {path} + + {toolData.isOutsideWorkspace && ( + + {" "} + โš  outside workspace + + )} + {toolData.isProtected && ๐Ÿ”’ protected} + + )} + + {/* Mode if present */} + {mode && ( + + mode: + + {mode} + + + )} + + {/* Content */} + {previewContent && ( + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/ModeTool.tsx b/apps/cli/src/ui/components/tools/ModeTool.tsx new file mode 100644 index 0000000000..29a95f12be --- /dev/null +++ b/apps/cli/src/ui/components/tools/ModeTool.tsx @@ -0,0 +1,86 @@ +/** + * Renderer for mode and task operations + * Handles: switchMode, newTask, finishTask + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_REASON_LINES = 5 + +export function ModeTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const mode = toolData.mode || "" + const reason = toolData.reason ? sanitizeContent(toolData.reason) : "" + const content = toolData.content ? sanitizeContent(toolData.content) : "" + + const isSwitch = toolData.tool.includes("switch") || toolData.tool.includes("Switch") + const isNewTask = toolData.tool.includes("new") || toolData.tool.includes("New") + const isFinish = toolData.tool.includes("finish") || toolData.tool.includes("Finish") + + const { text: previewReason, truncated } = truncateText(reason || content, MAX_REASON_LINES) + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + + + {/* Mode transition for switch */} + {isSwitch && mode && ( + + switching to: + + {mode} + + + )} + + {/* Mode for new task */} + {isNewTask && mode && ( + + mode: + + {mode} + + + )} + + {/* Finish task indicator */} + {isFinish && ( + + + Subtask completed + + + )} + + {/* Reason/message */} + {previewReason && ( + + {isNewTask ? "message:" : "reason:"} + + + {previewReason} + + + {truncated && ( + + ... + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/SearchTool.tsx b/apps/cli/src/ui/components/tools/SearchTool.tsx new file mode 100644 index 0000000000..94ea01df61 --- /dev/null +++ b/apps/cli/src/ui/components/tools/SearchTool.tsx @@ -0,0 +1,117 @@ +/** + * Renderer for search operations + * Handles: searchFiles, codebaseSearch + */ + +import { Box, Text } from "ink" + +import * as theme from "../../utils/theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_RESULT_LINES = 15 + +export function SearchTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const regex = toolData.regex || "" + const query = toolData.query || "" + const filePattern = toolData.filePattern || "" + const path = toolData.path || "" + const content = toolData.content ? sanitizeContent(toolData.content) : "" + + // Parse search results if content looks like results + const resultLines = content.split("\n").filter((line) => line.trim()) + const matchCount = resultLines.length + + const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_RESULT_LINES) + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + {matchCount > 0 && ({matchCount} matches)} + + + {/* Search parameters */} + + {/* Regex/Query */} + {regex && ( + + regex: + + {regex} + + + )} + {query && ( + + query: + + {query} + + + )} + + {/* Search scope */} + + {path && ( + <> + path: + {path} + + )} + {filePattern && ( + <> + pattern: + {filePattern} + + )} + + + + {/* Results */} + {previewContent && ( + + + Results: + + + {previewContent.split("\n").map((line, i) => { + // Try to highlight file:line patterns + const match = line.match(/^([^:]+):(\d+):(.*)$/) + if (match) { + const [, file, lineNum, context] = match + return ( + + {file} + : + {lineNum} + : + {context} + + ) + } + return ( + + {line} + + ) + })} + + {truncated && ( + + ... ({hiddenLines} more results) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx new file mode 100644 index 0000000000..04064e6487 --- /dev/null +++ b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx @@ -0,0 +1,164 @@ +import { render } from "ink-testing-library" + +import { CommandTool } from "../CommandTool.js" +import type { ToolRendererProps } from "../types.js" + +describe("CommandTool", () => { + describe("command display", () => { + it("displays the command when toolData.command is provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "npm test", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // Command should be displayed with $ prefix + expect(output).toContain("$") + expect(output).toContain("npm test") + }) + + it("does not display command section when toolData.command is empty", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The output should be displayed but no command line with $ + expect(output).toContain("All tests passed") + // Should not have a standalone $ followed by a command + // (just checking the output is present without command) + }) + + it("does not display command section when toolData.command is undefined", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The output should be displayed + expect(output).toContain("All tests passed") + }) + + it("displays command with complex arguments", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: 'git commit -m "fix: resolve issue"', + output: "[main abc123] fix: resolve issue", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("$") + expect(output).toContain('git commit -m "fix: resolve issue"') + }) + }) + + describe("output display", () => { + it("displays output when provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "echo hello", + output: "hello", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("hello") + }) + + it("displays multi-line output", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "ls", + output: "file1.txt\nfile2.txt\nfile3.txt", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("file1.txt") + expect(output).toContain("file2.txt") + expect(output).toContain("file3.txt") + }) + + it("uses content as fallback when output is not provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "ls", + content: "fallback content", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("fallback content") + }) + + it("truncates output to MAX_OUTPUT_LINES", () => { + // Create output with more than 10 lines (MAX_OUTPUT_LINES = 10) + const longOutput = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "cat longfile.txt", + output: longOutput, + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // First 10 lines should be visible + expect(output).toContain("line 1") + expect(output).toContain("line 10") + + // Should show truncation indicator + expect(output).toContain("more lines") + }) + }) + + describe("header display", () => { + it("displays terminal icon when rendered", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "echo test", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The terminal icon fallback is "$", which also appears before the command + expect(output).toContain("$") + expect(output).toContain("echo test") + }) + }) +}) diff --git a/apps/cli/src/ui/components/tools/index.ts b/apps/cli/src/ui/components/tools/index.ts new file mode 100644 index 0000000000..c628432002 --- /dev/null +++ b/apps/cli/src/ui/components/tools/index.ts @@ -0,0 +1,63 @@ +/** + * Tool renderer components for CLI TUI + * + * Each tool type has a specialized renderer that optimizes the display + * of its unique data structure. + */ + +import type React from "react" + +import type { ToolRendererProps } from "./types.js" +import { getToolCategory } from "./types.js" + +// Import all renderers +import { FileReadTool } from "./FileReadTool.js" +import { FileWriteTool } from "./FileWriteTool.js" +import { SearchTool } from "./SearchTool.js" +import { CommandTool } from "./CommandTool.js" +import { BrowserTool } from "./BrowserTool.js" +import { ModeTool } from "./ModeTool.js" +import { CompletionTool } from "./CompletionTool.js" +import { GenericTool } from "./GenericTool.js" + +// Re-export types +export type { ToolRendererProps } from "./types.js" +export { getToolCategory } from "./types.js" + +// Re-export utilities +export * from "./utils.js" + +// Re-export individual components for direct usage +export { FileReadTool } from "./FileReadTool.js" +export { FileWriteTool } from "./FileWriteTool.js" +export { SearchTool } from "./SearchTool.js" +export { CommandTool } from "./CommandTool.js" +export { BrowserTool } from "./BrowserTool.js" +export { ModeTool } from "./ModeTool.js" +export { CompletionTool } from "./CompletionTool.js" +export { GenericTool } from "./GenericTool.js" + +/** + * Map of tool categories to their renderer components + */ +const CATEGORY_RENDERERS: Record> = { + "file-read": FileReadTool, + "file-write": FileWriteTool, + search: SearchTool, + command: CommandTool, + browser: BrowserTool, + mode: ModeTool, + completion: CompletionTool, + other: GenericTool, +} + +/** + * Get the appropriate renderer component for a tool + * + * @param toolName - The tool name/identifier + * @returns The renderer component for this tool type + */ +export function getToolRenderer(toolName: string): React.FC { + const category = getToolCategory(toolName) + return CATEGORY_RENDERERS[category] || GenericTool +} diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts new file mode 100644 index 0000000000..65c7963307 --- /dev/null +++ b/apps/cli/src/ui/components/tools/types.ts @@ -0,0 +1,65 @@ +/** + * Types for tool renderer components + */ + +import type { ToolData } from "../../types.js" + +/** + * Props passed to all tool renderer components + */ +export interface ToolRendererProps { + /** Structured tool data */ + toolData: ToolData + /** Raw content fallback (JSON string) */ + rawContent?: string +} + +/** + * Tool category for grouping similar tools + */ +export type ToolCategory = + | "file-read" + | "file-write" + | "search" + | "command" + | "browser" + | "mode" + | "completion" + | "other" + +/** + * Get the category for a tool based on its name + */ +export function getToolCategory(toolName: string): ToolCategory { + const fileReadTools = [ + "readFile", + "read_file", + "fetchInstructions", + "fetch_instructions", + "listFilesTopLevel", + "listFilesRecursive", + "list_files", + ] + const fileWriteTools = [ + "editedExistingFile", + "appliedDiff", + "apply_diff", + "newFileCreated", + "write_to_file", + "writeToFile", + ] + const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"] + const commandTools = ["execute_command", "executeCommand"] + const browserTools = ["browser_action", "browserAction"] + const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"] + const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"] + + if (fileReadTools.includes(toolName)) return "file-read" + if (fileWriteTools.includes(toolName)) return "file-write" + if (searchTools.includes(toolName)) return "search" + if (commandTools.includes(toolName)) return "command" + if (browserTools.includes(toolName)) return "browser" + if (modeTools.includes(toolName)) return "mode" + if (completionTools.includes(toolName)) return "completion" + return "other" +} diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts new file mode 100644 index 0000000000..235e743067 --- /dev/null +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -0,0 +1,226 @@ +/** + * Utility functions for tool rendering + */ + +import type { IconName } from "../Icon.js" + +/** + * Truncate text and return truncation info + */ +export function truncateText( + text: string, + maxLines: number = 10, +): { text: string; truncated: boolean; totalLines: number; hiddenLines: number } { + const lines = text.split("\n") + const totalLines = lines.length + + if (lines.length <= maxLines) { + return { text, truncated: false, totalLines, hiddenLines: 0 } + } + + const truncatedText = lines.slice(0, maxLines).join("\n") + return { + text: truncatedText, + truncated: true, + totalLines, + hiddenLines: totalLines - maxLines, + } +} + +/** + * Sanitize content for terminal display + * - Replaces tabs with spaces + * - Strips carriage returns + */ +export function sanitizeContent(text: string): string { + return text.replace(/\t/g, " ").replace(/\r/g, "") +} + +/** + * Format diff stats as a colored string representation + */ +export function formatDiffStats(stats: { added: number; removed: number }): { added: string; removed: string } { + return { + added: `+${stats.added}`, + removed: `-${stats.removed}`, + } +} + +/** + * Get a friendly display name for a tool + */ +export function getToolDisplayName(toolName: string): string { + const displayNames: Record = { + // File read operations + readFile: "Read", + read_file: "Read", + fetchInstructions: "Fetch Instructions", + fetch_instructions: "Fetch Instructions", + listFilesTopLevel: "List Files", + listFilesRecursive: "List Files (Recursive)", + list_files: "List Files", + + // File write operations + editedExistingFile: "Edit", + appliedDiff: "Diff", + apply_diff: "Diff", + newFileCreated: "Create File", + write_to_file: "Write File", + writeToFile: "Write File", + + // Search operations + searchFiles: "Search Files", + search_files: "Search Files", + codebaseSearch: "Codebase Search", + codebase_search: "Codebase Search", + + // Command operations + execute_command: "Execute Command", + executeCommand: "Execute Command", + + // Browser operations + browser_action: "Browser Action", + browserAction: "Browser Action", + + // Mode operations + switchMode: "Switch Mode", + switch_mode: "Switch Mode", + newTask: "New Task", + new_task: "New Task", + finishTask: "Finish Task", + + // Completion operations + attempt_completion: "Task Complete", + attemptCompletion: "Task Complete", + ask_followup_question: "Question", + askFollowupQuestion: "Question", + + // TODO operations + update_todo_list: "Update TODO List", + updateTodoList: "Update TODO List", + } + + return displayNames[toolName] || toolName +} + +/** + * Get the IconName for a tool (for use with Icon component) + */ +export function getToolIconName(toolName: string): IconName { + const iconNames: Record = { + // File read operations + readFile: "file", + read_file: "file", + fetchInstructions: "file", + fetch_instructions: "file", + listFilesTopLevel: "folder", + listFilesRecursive: "folder", + list_files: "folder", + + // File write operations + editedExistingFile: "file-edit", + appliedDiff: "diff", + apply_diff: "diff", + newFileCreated: "file-edit", + write_to_file: "file-edit", + writeToFile: "file-edit", + + // Search operations + searchFiles: "search", + search_files: "search", + codebaseSearch: "search", + codebase_search: "search", + + // Command operations + execute_command: "terminal", + executeCommand: "terminal", + + // Browser operations + browser_action: "browser", + browserAction: "browser", + + // Mode operations + switchMode: "switch", + switch_mode: "switch", + newTask: "switch", + new_task: "switch", + finishTask: "check", + + // Completion operations + attempt_completion: "check", + attemptCompletion: "check", + ask_followup_question: "question", + askFollowupQuestion: "question", + + // TODO operations + update_todo_list: "check", + updateTodoList: "check", + } + + return iconNames[toolName] || "gear" +} + +/** + * Format a file path for display, optionally with workspace indicator + */ +export function formatPath(path: string, isOutsideWorkspace?: boolean, isProtected?: boolean): string { + let result = path + const badges: string[] = [] + + if (isOutsideWorkspace) { + badges.push("outside workspace") + } + + if (isProtected) { + badges.push("protected") + } + + if (badges.length > 0) { + result += ` (${badges.join(", ")})` + } + + return result +} + +/** + * Parse diff content into structured hunks for rendering + */ +export interface DiffHunk { + header: string + lines: Array<{ + type: "context" | "added" | "removed" | "header" + content: string + lineNumber?: number + }> +} + +export function parseDiff(diffContent: string): DiffHunk[] { + const hunks: DiffHunk[] = [] + const lines = diffContent.split("\n") + + let currentHunk: DiffHunk | null = null + + for (const line of lines) { + if (line.startsWith("@@")) { + // New hunk header + if (currentHunk) { + hunks.push(currentHunk) + } + currentHunk = { header: line, lines: [] } + } else if (currentHunk) { + if (line.startsWith("+") && !line.startsWith("+++")) { + currentHunk.lines.push({ type: "added", content: line.substring(1) }) + } else if (line.startsWith("-") && !line.startsWith("---")) { + currentHunk.lines.push({ type: "removed", content: line.substring(1) }) + } else if (line.startsWith(" ") || line === "") { + currentHunk.lines.push({ type: "context", content: line.substring(1) || "" }) + } + } + } + + if (currentHunk) { + hunks.push(currentHunk) + } + + return hunks +} diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index f1146c6fd8..d9763febf0 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -36,6 +36,91 @@ export type SayType = | "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 + + // File operation fields + /** File path */ + path?: string + /** Whether the file is outside the workspace */ + isOutsideWorkspace?: boolean + /** Whether the file is write-protected */ + isProtected?: boolean + /** Unified diff content */ + diff?: string + /** Diff statistics */ + diffStats?: { added: number; removed: number } + /** General content (file content, search results, etc.) */ + content?: string + + // Search operation fields + /** Search regex pattern */ + regex?: string + /** File pattern filter */ + filePattern?: string + /** Search query (for codebase search) */ + query?: string + + // Mode operation fields + /** Target mode slug */ + mode?: string + /** Reason for mode switch or other actions */ + reason?: string + + // Command operation fields + /** Command string */ + command?: string + /** Command output */ + output?: string + + // Browser operation fields + /** Browser action type */ + action?: string + /** Browser URL */ + url?: string + /** Click/hover coordinates */ + coordinate?: string + + // Batch operation fields + /** Batch file reads */ + batchFiles?: Array<{ + path: string + lineSnippet?: string + isOutsideWorkspace?: boolean + key?: string + content?: string + }> + /** Batch diff operations */ + batchDiffs?: Array<{ + path: string + changeCount?: number + key?: string + content?: string + diffStats?: { added: number; removed: number } + diffs?: Array<{ + content: string + startLine?: number + }> + }> + + // Question/completion fields + /** Question text for ask_followup_question */ + question?: string + /** Result text for attempt_completion */ + result?: string + + // Additional display hints + /** Line number for context */ + lineNumber?: number + /** Additional file count for batch operations */ + additionalFileCount?: number +} + export interface TUIMessage { id: string role: MessageRole @@ -50,6 +135,8 @@ export interface TUIMessage { todos?: TodoItem[] /** Previous TODO items for diff display */ previousTodos?: TodoItem[] + /** Structured tool data for rich rendering */ + toolData?: ToolData } export interface PendingAsk { diff --git a/apps/cli/src/ui/utils/globalInputSequences.ts b/apps/cli/src/ui/utils/globalInputSequences.ts index 8989ceea9c..792f38ee59 100644 --- a/apps/cli/src/ui/utils/globalInputSequences.ts +++ b/apps/cli/src/ui/utils/globalInputSequences.ts @@ -55,6 +55,19 @@ export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [ return false }, }, + { + id: "ctrl-t", + description: "Toggle TODO list viewer", + matches: (input, key) => { + // Standard Ctrl+T detection + if (key.ctrl && input === "t") return true + // CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol) + // 116 = 't' ASCII code, 5 = Ctrl modifier + if (input === "\x1b[116;5u") return true + if (input.endsWith("[116;5u")) return true + return false + }, + }, // Add more global sequences here as needed: // { // id: "ctrl-n",