mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
More progress
This commit is contained in:
parent
607390b94a
commit
511586d6bb
24 changed files with 1828 additions and 152 deletions
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
91
apps/cli/src/ui/components/tools/BrowserTool.tsx
Normal file
91
apps/cli/src/ui/components/tools/BrowserTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
apps/cli/src/ui/components/tools/CommandTool.tsx
Normal file
49
apps/cli/src/ui/components/tools/CommandTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
apps/cli/src/ui/components/tools/CompletionTool.tsx
Normal file
39
apps/cli/src/ui/components/tools/CompletionTool.tsx
Normal 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
|
||||
}
|
||||
135
apps/cli/src/ui/components/tools/FileReadTool.tsx
Normal file
135
apps/cli/src/ui/components/tools/FileReadTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
169
apps/cli/src/ui/components/tools/FileWriteTool.tsx
Normal file
169
apps/cli/src/ui/components/tools/FileWriteTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/cli/src/ui/components/tools/GenericTool.tsx
Normal file
97
apps/cli/src/ui/components/tools/GenericTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
apps/cli/src/ui/components/tools/ModeTool.tsx
Normal file
86
apps/cli/src/ui/components/tools/ModeTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
117
apps/cli/src/ui/components/tools/SearchTool.tsx
Normal file
117
apps/cli/src/ui/components/tools/SearchTool.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx
Normal file
164
apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
63
apps/cli/src/ui/components/tools/index.ts
Normal file
63
apps/cli/src/ui/components/tools/index.ts
Normal 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
|
||||
}
|
||||
65
apps/cli/src/ui/components/tools/types.ts
Normal file
65
apps/cli/src/ui/components/tools/types.ts
Normal 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"
|
||||
}
|
||||
226
apps/cli/src/ui/components/tools/utils.ts
Normal file
226
apps/cli/src/ui/components/tools/utils.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue