More progress

This commit is contained in:
cte 2026-01-07 22:34:18 -08:00
parent 607390b94a
commit 511586d6bb
24 changed files with 1828 additions and 152 deletions

View file

@ -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<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(null)
// Track Ctrl+C presses for "press again to exit" behavior
const [showExitHint, setShowExitHint] = useState(false)
const exitHintTimeout = useRef<NodeJS.Timeout | null>(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<AutocompletePickerState<any>>({
@ -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<string, unknown>
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<string, unknown>
@ -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 ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : isInputAreaActive ? (
<Text color={theme.dimText}>? for shortcuts Ctrl+M mode</Text>
<Text color={theme.dimText}>? for shortcuts</Text>
) : null
// Get render function for picker items based on active trigger
@ -1428,7 +1539,14 @@ function AppInner({
prompt=" "
/>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
{showTodoViewer ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<TodoDisplay todos={currentTodos} showProgress={true} title="TODO List" />
<Box height={1}>
<Text color={theme.dimText}>Ctrl+T to close</Text>
</Box>
</Box>
) : pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
@ -1464,6 +1582,114 @@ export function App(props: TUIAppProps) {
)
}
/**
* Extract structured ToolData from parsed tool JSON
* This provides rich data for tool-specific renderers
*/
function extractToolData(toolInfo: Record<string, unknown>): 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<Record<string, unknown>>).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<Record<string, unknown>>).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)
*/

View file

@ -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<string, string> = {
// 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<string, unknown> | 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 (
<Box flexDirection="column" paddingX={1}>
@ -280,17 +220,16 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) {
message.todos &&
message.todos.length > 0
) {
return (
<Box flexDirection="column">
<TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
<Text>
<Newline />
</Text>
</Box>
)
return <TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
}
// 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 <ToolRenderer toolData={message.toolData} rawContent={message.content} />
}
// Fallback to generic ToolDisplay for messages without toolData
return <ToolDisplay message={message} />
}
case "system":

View file

@ -51,15 +51,14 @@ function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contex
<Text color={theme.dimText}>Mode: {mode}</Text>
<Text color={theme.dimText}>Model: {model}</Text>
<Text color={theme.dimText}>Reasoning: {reasoningEffort}</Text>
{showMetrics && (
<Box marginTop={1}>
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
</Box>
)}
</Box>
</Box>
</Box>
{/* Inline horizontal line using the same columns value */}
{showMetrics && (
<Box alignSelf="flex-end" marginTop={-1}>
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
</Box>
)}
<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
</Box>
)

View file

@ -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<IconName, { nerd: string; fallback: string }> = {
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)

View file

@ -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) {
<Text color={theme.text}>{formatNumber(totalTokensOut)}</Text>
</Text>
<Text color={theme.dimText}> </Text>
<Text color={theme.dimText}>Context: </Text>
<ProgressBar value={contextTokens} max={contextWindow} width={12} />
</Box>
)

View file

@ -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<TodoItem["status"], IconName> = {
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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header with progress bar on same line */}
<Box>
<Icon name="todo-list" color={theme.toolHeader} />
<Text color={theme.toolHeader} bold>
{title}
{" "}
{title}
</Text>
</Box>
{/* Border top */}
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
{showProgress && (
<>
<Text> </Text>
<ProgressBar value={completedCount} max={totalCount} width={16} />
</>
)}
</Box>
{/* TODO items */}
<Box flexDirection="column" paddingLeft={1}>
<Box flexDirection="column" paddingLeft={1} marginTop={1}>
{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 (
<Box key={todo.id || `todo-${index}`}>
<Text color={color}>
{icon} {todo.content}
</Text>
<Icon name={iconName} color={color} />
<Text color={color}> {todo.content}</Text>
{statusChanged && (
<Text color={theme.dimText} dimColor>
{" "}
@ -155,23 +156,6 @@ function TodoDisplay({
)
})}
</Box>
{/* Progress bar and stats */}
{showProgress && (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
</Box>
<Box paddingLeft={1}>
<ProgressBar value={completedCount} max={totalCount} width={16} />
<Text color={theme.dimText}>
{" "}
{completedCount}/{totalCount} completed
{inProgressCount > 0 && `, ${inProgressCount} in progress`}
</Text>
</Box>
</Box>
)}
</Box>
)
}

View file

@ -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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
const output = lastFrame()
// CompletionTool renders the question content directly without icon or header
expect(output).toContain("What color would you like?")
})
})
})

View file

@ -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(<TodoDisplay todos={mockTodos} />)
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(<TodoDisplay todos={mockTodos} showProgress={true} />)
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(<TodoDisplay todos={todosWithMultipleInProgress} showProgress={true} />)
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
})
})

View file

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

View file

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

View file

@ -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<string, string> = {
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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{action && (
<Text color={theme.focusColor} bold>
{" "}
{actionLabel}
</Text>
)}
</Box>
{/* Action details */}
<Box flexDirection="column" marginLeft={2}>
{/* URL for launch action */}
{url && (
<Box>
<Text color={theme.dimText}>url: </Text>
<Text color={theme.text} underline>
{url}
</Text>
</Box>
)}
{/* Coordinates for click/hover actions */}
{coordinate && (
<Box>
<Text color={theme.dimText}>at: </Text>
<Text color={theme.warningColor}>{coordinate}</Text>
</Box>
)}
{/* Text content for type action */}
{content && action === "type" && (
<Box>
<Text color={theme.dimText}>text: </Text>
<Text color={theme.text}>"{content}"</Text>
</Box>
)}
{/* Key for press action */}
{content && action === "press" && (
<Box>
<Text color={theme.dimText}>key: </Text>
<Text color={theme.successColor}>{content}</Text>
</Box>
)}
</Box>
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Box>
<Icon name={iconName} color={theme.toolHeader} />
{command && (
<Box marginLeft={1}>
<Text color={theme.successColor}>$ </Text>
<Text color={theme.text} bold>
{command}
</Text>
</Box>
)}
</Box>
{previewOutput && (
<Box flexDirection="column">
<Box flexDirection="column" borderStyle="single" borderColor={theme.borderColor} paddingX={1}>
{previewOutput.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 ? (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{isQuestion ? (
<Box flexDirection="column">
<Text color={theme.text}>{previewContent}</Text>
</Box>
) : (
<Box flexDirection="column">
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
)}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
) : null
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
<Text color={theme.dimText}> ({toolData.batchFiles.length} files)</Text>
</Box>
{/* File list */}
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{toolData.batchFiles.slice(0, 10).map((file, index) => (
<Box key={index}>
<Text color={theme.text} bold>
{file.path}
</Text>
{file.lineSnippet && <Text color={theme.dimText}> ({file.lineSnippet})</Text>}
{file.isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</Box>
))}
{toolData.batchFiles.length > 10 && (
<Text color={theme.dimText}>... and {toolData.batchFiles.length - 10} more files</Text>
)}
</Box>
</Box>
)
}
// Single file read
const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_PREVIEW_LINES)
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header with path on same line for single file */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{displayName}
</Text>
{path && (
<>
<Text color={theme.dimText}> · </Text>
<Text color={theme.text} bold>
{path}
</Text>
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</>
)}
</Box>
{/* Content preview - only if we have actual file content */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{isList ? (
// Directory listing - show as tree-like structure
<Box flexDirection="column">
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
) : (
// File content - show in a box
<Box flexDirection="column">
<Box borderStyle="single" borderColor={theme.borderColor} paddingX={1}>
<Text color={theme.toolText}>{previewContent}</Text>
</Box>
</Box>
)}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
<Text color={theme.dimText}> ({toolData.batchDiffs.length} files)</Text>
</Box>
{/* File list with stats */}
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{toolData.batchDiffs.slice(0, 8).map((file, index) => (
<Box key={index}>
<Text color={theme.text} bold>
{file.path}
</Text>
{file.diffStats && (
<Box marginLeft={1}>
<Text color={theme.successColor}>+{file.diffStats.added}</Text>
<Text color={theme.dimText}> / </Text>
<Text color={theme.errorColor}>-{file.diffStats.removed}</Text>
</Box>
)}
</Box>
))}
{toolData.batchDiffs.length > 8 && (
<Text color={theme.dimText}>... and {toolData.batchDiffs.length - 8} more files</Text>
)}
</Box>
</Box>
)
}
// Single file write
const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES)
const diffHunks = diff ? parseDiff(diff) : []
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header row with path on same line */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{displayName}
</Text>
{path && (
<>
<Text color={theme.dimText}> · </Text>
<Text color={theme.text} bold>
{path}
</Text>
</>
)}
{isNewFile && (
<Text color={theme.successColor} bold>
{" "}
NEW
</Text>
)}
{/* Diff stats badge */}
{diffStats && (
<>
<Text color={theme.dimText}> </Text>
<Text color={theme.successColor} bold>
+{diffStats.added}
</Text>
<Text color={theme.dimText}>/</Text>
<Text color={theme.errorColor} bold>
-{diffStats.removed}
</Text>
</>
)}
{/* Warning badges */}
{isProtected && <Text color={theme.errorColor}> 🔒 protected</Text>}
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</Box>
{/* Diff preview */}
{diffHunks.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
<Box key={hunkIndex} flexDirection="column">
{/* Hunk header */}
<Text color={theme.focusColor} dimColor>
{hunk.header}
</Text>
{/* Diff lines */}
{hunk.lines.slice(0, 8).map((line, lineIndex) => (
<Text
key={lineIndex}
color={
line.type === "added"
? theme.successColor
: line.type === "removed"
? theme.errorColor
: theme.toolText
}>
{line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
{line.content}
</Text>
))}
{hunk.lines.length > 8 && (
<Text color={theme.dimText} dimColor>
... ({hunk.lines.length - 8} more lines in hunk)
</Text>
)}
</Box>
))}
{diffHunks.length > 2 && (
<Text color={theme.dimText} dimColor>
... ({diffHunks.length - 2} more hunks)
</Text>
)}
</Box>
)}
{/* Fallback to raw diff if no hunks parsed */}
{diffHunks.length === 0 && previewDiff && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.toolText}>{previewDiff}</Text>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
</Box>
{/* Path if present */}
{path && (
<Box marginLeft={2}>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text} bold>
{path}
</Text>
{toolData.isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
{toolData.isProtected && <Text color={theme.errorColor}> 🔒 protected</Text>}
</Box>
)}
{/* Mode if present */}
{mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>mode: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Content */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={path || mode ? 1 : 0}>
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
</Box>
{/* Mode transition for switch */}
{isSwitch && mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>switching to: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Mode for new task */}
{isNewTask && mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>mode: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Finish task indicator */}
{isFinish && (
<Box marginLeft={2}>
<Text color={theme.successColor} bold>
Subtask completed
</Text>
</Box>
)}
{/* Reason/message */}
{previewReason && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.dimText}>{isNewTask ? "message:" : "reason:"}</Text>
<Box marginLeft={1}>
<Text color={theme.toolText} italic>
{previewReason}
</Text>
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
...
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{matchCount > 0 && <Text color={theme.dimText}> ({matchCount} matches)</Text>}
</Box>
{/* Search parameters */}
<Box flexDirection="column" marginLeft={2}>
{/* Regex/Query */}
{regex && (
<Box>
<Text color={theme.dimText}>regex: </Text>
<Text color={theme.warningColor} bold>
{regex}
</Text>
</Box>
)}
{query && (
<Box>
<Text color={theme.dimText}>query: </Text>
<Text color={theme.warningColor} bold>
{query}
</Text>
</Box>
)}
{/* Search scope */}
<Box>
{path && (
<>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text}>{path}</Text>
</>
)}
{filePattern && (
<>
<Text color={theme.dimText}> pattern: </Text>
<Text color={theme.text}>{filePattern}</Text>
</>
)}
</Box>
</Box>
{/* Results */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.dimText} bold>
Results:
</Text>
<Box flexDirection="column" marginTop={0}>
{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 (
<Box key={i}>
<Text color={theme.focusColor}>{file}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.warningColor}>{lineNum}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.toolText}>{context}</Text>
</Box>
)
}
return (
<Text key={i} color={theme.toolText}>
{line}
</Text>
)
})}
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more results)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
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(<CommandTool {...props} />)
const output = lastFrame()
// The terminal icon fallback is "$", which also appears before the command
expect(output).toContain("$")
expect(output).toContain("echo test")
})
})
})

View file

@ -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<string, React.FC<ToolRendererProps>> = {
"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<ToolRendererProps> {
const category = getToolCategory(toolName)
return CATEGORY_RENDERERS[category] || GenericTool
}

View file

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

View file

@ -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<string, string> = {
// 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<string, IconName> = {
// 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
}

View file

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

View file

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