diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts index af46f945ff..63c9232181 100644 --- a/apps/cli/src/__tests__/extension-host.test.ts +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -13,6 +13,7 @@ vi.mock("@roo-code/vscode-shim", () => ({ createVSCodeAPI: vi.fn(() => ({ context: { extensionPath: "/test/extension" }, })), + setRuntimeConfigValues: vi.fn(), })) /** @@ -1131,22 +1132,6 @@ describe("ExtensionHost", () => { await expect(promise).rejects.toThrow("Test error") }) - - it("should timeout after configured duration", async () => { - const host = createTestHost() - - // Use fake timers for this test - vi.useFakeTimers() - - const promise = callPrivate>(host, "waitForCompletion") - - // Fast-forward past the timeout (10 minutes) - vi.advanceTimersByTime(10 * 60 * 1000 + 1) - - await expect(promise).rejects.toThrow("Task timed out") - - vi.useRealTimers() - }) }) describe("handleStateMessage - mode tracking", () => { @@ -1221,6 +1206,160 @@ describe("ExtensionHost", () => { }) }) + describe("applyRuntimeSettings - mode switching", () => { + it("should use currentMode when set (from user mode switches)", () => { + const host = createTestHost({ + mode: "code", // Initial mode from CLI options + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + + // Simulate user switching mode via Ctrl+M - this updates currentMode + ;(host as unknown as Record).currentMode = "architect" + + // Create settings object to be modified + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + + // Should use currentMode (architect), not options.mode (code) + expect(settings.mode).toBe("architect") + }) + + it("should fall back to options.mode when currentMode is not set", () => { + const host = createTestHost({ + mode: "code", + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + + // currentMode is not set (still null from constructor) + expect(getPrivate(host, "currentMode")).toBe("code") // Set from options.mode in constructor + + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + + // Should use options.mode as fallback + expect(settings.mode).toBe("code") + }) + + it("should use currentMode even when it differs from initial options.mode", () => { + const host = createTestHost({ + mode: "code", + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + + // Simulate multiple mode switches: code -> architect -> debug + ;(host as unknown as Record).currentMode = "debug" + + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + + // Should use the latest currentMode + expect(settings.mode).toBe("debug") + }) + + it("should not set mode if neither currentMode nor options.mode is set", () => { + const host = createTestHost({ + // No mode specified - mode defaults to "code" in createTestHost + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + + // Explicitly set currentMode to null (edge case) + ;(host as unknown as Record).currentMode = null + // Also clear options.mode + const options = getPrivate(host, "options") + options.mode = "" + + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + + // Mode should not be set + expect(settings.mode).toBeUndefined() + }) + }) + + describe("mode switching - end to end simulation", () => { + let host: ExtensionHost + + beforeEach(() => { + host = createTestHost({ + mode: "code", + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should preserve mode switch when starting a new task", () => { + // Step 1: Initial state from extension (like webviewDidLaunch response) + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "code", clineMessages: [] }, + }) + expect(getPrivate(host, "currentMode")).toBe("code") + + // Step 2: User presses Ctrl+M to switch mode, extension sends new state + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "architect", clineMessages: [] }, + }) + expect(getPrivate(host, "currentMode")).toBe("architect") + + // Step 3: When runTask is called, applyRuntimeSettings should use architect + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + expect(settings.mode).toBe("architect") + }) + + it("should handle mode switch before any state messages", () => { + // currentMode is initialized to options.mode in constructor + expect(getPrivate(host, "currentMode")).toBe("code") + + // Without any state messages, should still use options.mode + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + expect(settings.mode).toBe("code") + }) + + it("should track multiple mode switches correctly", () => { + // Switch through multiple modes + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "code", clineMessages: [] }, + }) + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "architect", clineMessages: [] }, + }) + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "debug", clineMessages: [] }, + }) + callPrivate(host, "handleStateMessage", { + type: "state", + state: { mode: "ask", clineMessages: [] }, + }) + + // Should use the most recent mode + expect(getPrivate(host, "currentMode")).toBe("ask") + + const settings: Record = {} + callPrivate(host, "applyRuntimeSettings", settings) + expect(settings.mode).toBe("ask") + }) + }) + describe("ephemeral mode", () => { describe("constructor", () => { it("should store ephemeral option", () => { diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts index 227f0780eb..28016d22ca 100644 --- a/apps/cli/src/extension-host.ts +++ b/apps/cli/src/extension-host.ts @@ -24,8 +24,12 @@ import { WebviewMessage, } from "@roo-code/types" import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" -import { debugLog } from "@roo-code/core/debug-log" +import { debugLog, DebugLogger } from "@roo-code/core/debug-log" import { FOLLOWUP_TIMEOUT_SECONDS } from "./constants.js" +import { toolInspectorLog, clearToolInspectorLog } from "./utils/toolInspectorLogger.js" + +// Pre-configured logger for CLI message activity debugging +const cliLogger = new DebugLogger("CLI") // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) // When bundled, import.meta.url points to dist/index.js, so go up to package root @@ -273,6 +277,11 @@ export class ExtensionHost extends EventEmitter { } catch (error) { throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) } + + // 8. Set up message listener to capture state updates (including mode changes) + // This needs to happen early so mode switches before a task runs are captured + this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message) + this.on("extensionWebviewMessage", this.messageListener) } /** @@ -334,8 +343,11 @@ export class ExtensionHost extends EventEmitter { } private applyRuntimeSettings(settings: RooCodeSettings): void { - if (this.options.mode) { - settings.mode = this.options.mode + // Use currentMode (updated by state messages from mode switches) if set, + // otherwise fall back to initial mode from CLI options + const activeMode = this.currentMode || this.options.mode + if (activeMode) { + settings.mode = activeMode } if (this.options.reasoningEffort) { @@ -540,15 +552,26 @@ export class ExtensionHost extends EventEmitter { } async runTask(prompt: string): Promise { + // Clear tool inspector log for fresh session (non-TUI mode) + clearToolInspectorLog() + toolInspectorLog("session:start:non-tui", { + timestamp: new Date().toISOString(), + mode: this.options.mode, + nonInteractive: this.options.nonInteractive, + }) + + cliLogger.debug("runTask:start", { + promptPreview: prompt?.substring(0, 100), + isWebviewReady: this.isWebviewReady, + }) + if (!this.isWebviewReady) { await new Promise((resolve) => { this.once("webviewReady", resolve) }) } - // Should this only be done once? - this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message) - this.on("extensionWebviewMessage", this.messageListener) + // Message listener is now set up in activate() to capture mode changes before first task const baseSettings: RooCodeSettings = { commandExecutionTimeout: 30, @@ -585,6 +608,13 @@ export class ExtensionHost extends EventEmitter { } private handleExtensionMessage(msg: ExtensionMessage): void { + // Log all incoming extension messages for debugging + cliLogger.debug("handleExtensionMessage", { + type: msg.type, + hasClineMessage: msg.clineMessage?.ts ? true : false, + hasState: msg.state ? true : false, + }) + switch (msg.type) { case "state": this.handleStateMessage(msg) @@ -649,10 +679,28 @@ export class ExtensionHost extends EventEmitter { this.currentMode = newMode } - // Why do we do this? + // Log state summary for debugging const clineMessages = state.clineMessages + cliLogger.debug("handleStateMessage", { + mode: newMode, + messageCount: clineMessages?.length ?? 0, + pendingAsksCount: this.pendingAsks.size, + pendingAsks: Array.from(this.pendingAsks), + isWebviewReady: this.isWebviewReady, + }) if (clineMessages && clineMessages.length > 0) { + // Log summary of recent messages + const recentMessages = clineMessages.slice(-5).map((m) => ({ + ts: m?.ts, + type: m?.type, + say: m?.say, + ask: m?.ask, + partial: m?.partial, + textPreview: m?.text?.substring(0, 100), + })) + cliLogger.debug("handleStateMessage:recentMessages", recentMessages) + for (const message of clineMessages) { if (!message) { continue @@ -683,13 +731,30 @@ export class ExtensionHost extends EventEmitter { * This is where real-time streaming happens! */ private handleMessageUpdated({ clineMessage: msg }: ExtensionMessage): void { - // if (msg?.ask) { - // this.log(`[MSG] ts=${msg.ts}, ask=${msg.ask} -> ${msg.partial ? "partial" : msg.text}`) - // } else if (msg?.say) { - // this.log(`[MSG] ts=${msg.ts}, say=${msg.say} -> ${msg.partial ? "partial" : msg.text}`) - // } else if (msg) { - // this.log(`[MSG] ts=${msg.ts}, type=${msg.type}, text=${msg.text}`) - // } + // Log message updates for debugging stuck states + if (msg?.ask) { + cliLogger.debug("messageUpdated:ask", { + ts: msg.ts, + ask: msg.ask, + partial: msg.partial, + textPreview: msg.text?.substring(0, 100), + pendingAsksCount: this.pendingAsks.size, + hasPendingForTs: this.pendingAsks.has(msg.ts), + }) + } else if (msg?.say) { + cliLogger.debug("messageUpdated:say", { + ts: msg.ts, + say: msg.say, + partial: msg.partial, + textPreview: msg.text?.substring(0, 100), + }) + } else if (msg) { + cliLogger.debug("messageUpdated:other", { + ts: msg.ts, + type: msg.type, + textPreview: msg.text?.substring(0, 100), + }) + } if (msg?.type === "say" && msg.say && typeof msg.text === "string") { this.handleSayMessage(msg.ts, msg.say, msg.text, msg.partial) @@ -886,6 +951,12 @@ export class ExtensionHost extends EventEmitter { // Check if we already handled this ask. if (this.pendingAsks.has(ts)) { + cliLogger.debug("handleAskMessage:skipped", { + ts, + ask, + reason: "alreadyPending", + pendingAsksCount: this.pendingAsks.size, + }) return } @@ -895,9 +966,23 @@ export class ExtensionHost extends EventEmitter { // If we don't check this first, the non-interactive handler would capture stdin input // that's meant for the TUI (e.g., arrow keys show as "[B[B[B[B" escape codes). if (this.options.disableOutput) { + cliLogger.debug("handleAskMessage:skipped", { + ts, + ask, + reason: "disableOutput (TUI mode)", + }) return } + // Log that we're processing a new ask + cliLogger.debug("handleAskMessage:processing", { + ts, + ask, + textPreview: text?.substring(0, 100), + mode: this.options.nonInteractive ? "nonInteractive" : "interactive", + pendingAsksCount: this.pendingAsks.size, + }) + // In non-interactive mode (without TUI), the extension's auto-approval settings handle // most things, but followup questions need special handling with timeout. if (this.options.nonInteractive) { @@ -924,6 +1009,12 @@ export class ExtensionHost extends EventEmitter { // In non-interactive mode, still prompt the user but with a 10s timeout // that auto-selects the first option if no input is received. this.pendingAsks.add(ts) + cliLogger.debug("pendingAsks:added (nonInteractive)", { + ts, + ask: "followup", + pendingAsksCount: this.pendingAsks.size, + waitingForUserInput: true, + }) this.handleFollowupQuestionWithTimeout(ts, text) this.displayedMessages.set(ts, { text, partial: false }) } @@ -946,6 +1037,14 @@ export class ExtensionHost extends EventEmitter { try { toolInfo = JSON.parse(text) toolName = toolInfo.tool || "unknown" + + // Log tool payload for inspection (non-interactive plaintext mode) + toolInspectorLog("ask:tool:plaintext:nonInteractive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + }) + this.output(`\n[tool] ${toolName}`) // Display all tool parameters (excluding 'tool' which is the name). @@ -1028,6 +1127,13 @@ export class ExtensionHost extends EventEmitter { private handleAskMessageInteractive(ts: number, ask: string, text: string): void { // Mark this ask as pending so we don't handle it again this.pendingAsks.add(ts) + cliLogger.debug("pendingAsks:added", { + ts, + ask, + pendingAsksCount: this.pendingAsks.size, + allPendingAsks: Array.from(this.pendingAsks), + waitingForUserInput: true, + }) switch (ask) { case "followup": @@ -1344,6 +1450,13 @@ export class ExtensionHost extends EventEmitter { try { toolInfo = JSON.parse(text) as Record toolName = (toolInfo.tool as string) || "unknown" + + // Log tool payload for inspection (interactive plaintext mode) + toolInspectorLog("ask:tool:plaintext:interactive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + }) } catch { // Use raw text if not JSON. } @@ -1546,6 +1659,12 @@ export class ExtensionHost extends EventEmitter { // Send approval response (only once per ts). if (!this.pendingAsks.has(ts)) { this.pendingAsks.add(ts) + cliLogger.debug("pendingAsks:added (command_output)", { + ts, + ask: "command_output", + pendingAsksCount: this.pendingAsks.size, + autoApproved: true, + }) this.sendApprovalResponse(true) } } @@ -1610,6 +1729,10 @@ export class ExtensionHost extends EventEmitter { * Send a followup response (text answer) to the extension */ private sendFollowupResponse(text: string): void { + cliLogger.debug("sendFollowupResponse", { + textPreview: text?.substring(0, 100), + pendingAsksCount: this.pendingAsks.size, + }) this.sendToExtension({ type: "askResponse", askResponse: "messageResponse", text }) } @@ -1617,6 +1740,10 @@ export class ExtensionHost extends EventEmitter { * Send an approval response (yes/no) to the extension */ private sendApprovalResponse(approved: boolean): void { + cliLogger.debug("sendApprovalResponse", { + approved, + pendingAsksCount: this.pendingAsks.size, + }) this.sendToExtension({ type: "askResponse", askResponse: approved ? "yesButtonClicked" : "noButtonClicked", @@ -1645,19 +1772,6 @@ export class ExtensionHost extends EventEmitter { this.once("taskComplete", completeHandler) this.once("taskError", errorHandler) - - // Set a timeout (10 minutes by default). - const timeout = setTimeout( - () => { - cleanup() - reject(new Error("Task timed out")) - }, - 10 * 60 * 1000, - ) - - // Clear timeout on completion. - this.once("taskComplete", () => clearTimeout(timeout)) - this.once("taskError", () => clearTimeout(timeout)) }) } diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx index b334ca0416..da4c979616 100644 --- a/apps/cli/src/ui/App.tsx +++ b/apps/cli/src/ui/App.tsx @@ -7,6 +7,8 @@ import { randomUUID } from "crypto" import type { ClineMessage, TodoItem, WebviewMessage } from "@roo-code/types" // Import only message-utils to avoid custom-tools dependencies (execa/child_process) import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils" +import { toolInspectorLog, clearToolInspectorLog } from "../utils/toolInspectorLogger.js" +import { arePathsEqual } from "../utils/pathUtils.js" import { useCLIStore } from "./store.js" import { getContextWindow } from "../utils/getContextWindow.js" @@ -22,15 +24,18 @@ import { createSlashCommandTrigger, createModeTrigger, createHelpTrigger, + createHistoryTrigger, toFileResult, toSlashCommandResult, toModeResult, + toHistoryResult, type AutocompleteInputHandle, type AutocompletePickerState, type AutocompleteTrigger, type FileResult, type SlashCommandResult as SlashCommandItem, type ModeResult as ModeItem, + type HistoryResult, } from "./components/autocomplete/index.js" import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js" import ScrollIndicator from "./components/ScrollIndicator.js" @@ -48,6 +53,7 @@ import type { FileSearchResult, SlashCommandResult, ModeResult, + TaskHistoryItem, } from "./types.js" import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js" @@ -182,9 +188,11 @@ function AppInner({ fileSearchResults, allSlashCommands, availableModes, + taskHistory, setFileSearchResults, setAllSlashCommands, setAvailableModes, + setTaskHistory, currentMode, setCurrentMode, tokenUsage, @@ -213,6 +221,7 @@ function AppInner({ const fileSearchResultsRef = useRef(fileSearchResults) const allSlashCommandsRef = useRef(allSlashCommands) const availableModesRef = useRef(availableModes) + const taskHistoryRef = useRef(taskHistory) // Keep refs in sync with current state useEffect(() => { @@ -224,6 +233,9 @@ function AppInner({ useEffect(() => { availableModesRef.current = availableModes }, [availableModes]) + useEffect(() => { + taskHistoryRef.current = taskHistory + }, [taskHistory]) // Track seen message timestamps to filter duplicates and the prompt echo const seenMessageIds = useRef>(new Set()) @@ -330,10 +342,10 @@ function AppInner({ }, []) // Create autocomplete triggers - // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult) + // Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult) // IMPORTANT: We use refs here to avoid recreating triggers every time data changes. // This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state - // The getResults/getCommands/getModes callbacks always read from refs to get fresh data. + // The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data. // eslint-disable-next-line @typescript-eslint/no-explicit-any const autocompleteTriggers = useMemo((): AutocompleteTrigger[] => { const fileTrigger = createFileTrigger({ @@ -360,8 +372,20 @@ function AppInner({ const helpTrigger = createHelpTrigger() - return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger] - }, [handleFileSearch]) // Only depend on handleFileSearch - data accessed via refs + // History trigger - type # to search and resume previous tasks + const historyTrigger = createHistoryTrigger({ + getHistory: () => { + // Filter to only show tasks for the current workspace + // Use arePathsEqual for proper cross-platform path comparison + // (handles trailing slashes, separators, and case sensitivity) + const history = taskHistoryRef.current + const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath)) + return filtered.map(toHistoryResult) + }, + }) + + return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger] + }, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs // Handle Ctrl+C, Tab for focus switching, Escape to cancel task, and Ctrl+M for mode cycling useInput((input, key) => { @@ -580,6 +604,15 @@ function AppInner({ role = "tool" try { const toolInfo = JSON.parse(text) + + // Log tool payload for inspection + toolInspectorLog("say:tool", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + partial, + }) + toolName = toolInfo.tool toolDisplayName = toolInfo.tool toolDisplayOutput = formatToolOutput(toolInfo) @@ -655,6 +688,9 @@ function AppInner({ if (ask === "resume_task" || ask === "resume_completed_task") { seenMessageIds.current.add(messageId) setLoading(false) + // Mark that a task has been started so subsequent messages continue the task + // (instead of starting a brand new task via runTask) + setHasStartedTask(true) // Do not set pendingAsk - let the normal text input appear return } @@ -677,6 +713,15 @@ function AppInner({ try { const toolInfo = JSON.parse(text) as Record + + // Log tool payload for inspection (nonInteractive ask) + toolInspectorLog("ask:tool:nonInteractive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + partial, + }) + toolName = toolInfo.tool as string toolDisplayName = toolInfo.tool as string toolDisplayOutput = formatToolOutput(toolInfo) @@ -719,6 +764,15 @@ function AppInner({ } else if (ask === "tool") { try { const toolInfo = JSON.parse(text) as Record + + // Log tool payload for inspection (interactive ask) + toolInspectorLog("ask:tool:interactive", { + ts, + rawText: text, + parsedToolInfo: toolInfo, + partial, + }) + questionText = formatToolAskMessage(toolInfo) } catch { // Use raw text if not valid JSON @@ -751,6 +805,13 @@ function AppInner({ if (newMode) { setCurrentMode(newMode) } + + // Extract and update task history from state + const newTaskHistory = state.taskHistory as TaskHistoryItem[] | undefined + if (newTaskHistory && Array.isArray(newTaskHistory)) { + setTaskHistory(newTaskHistory) + } + const clineMessages = state.clineMessages as Array> | undefined if (clineMessages) { for (const clineMsg of clineMessages) { @@ -849,12 +910,22 @@ function AppInner({ setTokenUsage, setRouterModels, setApiConfiguration, + setTaskHistory, ], ) // Initialize extension host useEffect(() => { const init = async () => { + // Clear tool inspector log for fresh session + clearToolInspectorLog() + + toolInspectorLog("session:start", { + timestamp: new Date().toISOString(), + mode, + nonInteractive, + }) + try { const host = createExtensionHost({ mode, @@ -892,6 +963,8 @@ function AppInner({ await host.activate() + // Request initial state from extension (triggers postStateToWebview which includes taskHistory) + host.sendToExtension({ type: "webviewDidLaunch" }) host.sendToExtension({ type: "requestCommands" }) host.sendToExtension({ type: "requestModes" }) @@ -946,7 +1019,8 @@ function AppInner({ seenMessageIds.current.clear() firstTextMessageSkipped.current = false hostRef.current.sendToExtension({ type: "clearTask" }) - // Re-request commands and modes since reset() cleared them. + // Re-request state, commands and modes since reset() cleared them. + hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) hostRef.current.sendToExtension({ type: "requestCommands" }) hostRef.current.sendToExtension({ type: "requestModes" }) return @@ -1080,6 +1154,39 @@ function AppInner({ hostRef.current.sendToExtension({ type: "switchMode", mode: modeItem.slug }) } + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } + // Check if this is a history item selection + else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) { + const historyItem = item as HistoryResult + + // Don't allow task switching while a task is in progress (loading) + if (isLoading) { + showInfo("Cannot switch tasks while task is in progress", 2000) + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + + // Send showTaskWithId message to extension to resume the task + if (hostRef.current) { + // Reset CLI state before resuming task + useCLIStore.getState().reset() + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + + // Send message to resume the selected task + hostRef.current.sendToExtension({ type: "showTaskWithId", text: historyItem.id }) + + // Re-request state, commands and modes since reset() cleared them + hostRef.current.sendToExtension({ type: "webviewDidLaunch" }) + hostRef.current.sendToExtension({ type: "requestCommands" }) + hostRef.current.sendToExtension({ type: "requestModes" }) + } + // Close the picker autocompleteRef.current?.closePicker() followupAutocompleteRef.current?.closePicker() @@ -1089,7 +1196,7 @@ function AppInner({ followupAutocompleteRef.current?.handleItemSelect(item) } }, - [pickerState.activeTrigger], + [pickerState.activeTrigger, isLoading, showInfo], ) // Handle picker close from external PickerSelect diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index 21ab685af3..947756f29b 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -5,6 +5,103 @@ import * as theme from "../utils/theme.js" import type { TUIMessage } from "../types.js" import TodoDisplay from "./TodoDisplay.js" +/** + * Default icon for unknown tools + */ +const DEFAULT_TOOL_ICON = "🔧" + +/** + * Tool icons for visual identification + */ +const TOOL_ICONS: Record = { + // File operations + readFile: "📄", + read_file: "📄", + writeToFile: "📝", + write_to_file: "📝", + applyDiff: "✏️", + apply_diff: "✏️", + + // Directory operations + listFiles: "📁", + list_files: "📁", + listFilesRecursive: "📂", + listFilesTopLevel: "📁", + + // Search + searchFiles: "🔍", + search_files: "🔍", + + // Commands + executeCommand: "💻", + execute_command: "💻", + + // Browser + browserAction: "🌐", + browser_action: "🌐", + + // Mode/Task + switchMode: "🔀", + switch_mode: "🔀", + newTask: "📋", + new_task: "📋", + + // Questions/Completion + askFollowupQuestion: "❓", + ask_followup_question: "❓", + attemptCompletion: "✅", + attempt_completion: "✅", + + // TODO + updateTodoList: "☑️", + update_todo_list: "☑️", +} + +/** + * Get the icon for a tool + */ +function getToolIcon(toolName: string): string { + return TOOL_ICONS[toolName] ?? DEFAULT_TOOL_ICON +} + +/** + * Tool categories for styling + */ +type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other" + +function getToolCategory(toolName: string): ToolCategory { + const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"] + const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"] + const searchTools = ["searchFiles", "search_files"] + const commandTools = ["executeCommand", "execute_command"] + const browserTools = ["browserAction", "browser_action"] + const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"] + const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"] + + if (fileTools.includes(toolName)) return "file" + if (dirTools.includes(toolName)) return "directory" + 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" +} + +/** + * Category colors for tool types + */ +const CATEGORY_COLORS: Record = { + file: theme.toolHeader, + directory: theme.toolHeader, + search: theme.warningColor, + command: theme.successColor, + browser: theme.focusColor, + mode: theme.userHeader, + completion: theme.successColor, + other: theme.toolHeader, +} + /** * Sanitize content for terminal display by: * - Replacing tab characters with spaces (tabs expand to variable widths in terminals) @@ -14,6 +111,124 @@ function sanitizeContent(text: string): string { return text.replace(/\t/g, " ").replace(/\r/g, "") } +/** + * Truncate content for display, showing line count + */ +function truncateContent( + content: string, + maxLines: number = 10, +): { text: string; truncated: boolean; totalLines: number } { + const lines = content.split("\n") + const totalLines = lines.length + + if (lines.length <= maxLines) { + return { text: content, truncated: false, totalLines } + } + + const truncatedText = lines.slice(0, maxLines).join("\n") + return { text: truncatedText, truncated: true, totalLines } +} + +/** + * Parse tool info from raw JSON content + */ +function parseToolInfo(content: string): Record | null { + try { + return JSON.parse(content) + } catch { + return null + } +} + +/** + * Render tool display component + */ +function ToolDisplay({ message }: { message: TUIMessage }) { + const toolName = message.toolName || "unknown" + const icon = getToolIcon(toolName) + const category = getToolCategory(toolName) + const categoryColor = CATEGORY_COLORS[category] + + // Try to parse the raw content for additional tool info + const toolInfo = parseToolInfo(message.content || "") + + // Extract key fields from tool info + const path = toolInfo?.path as string | undefined + const isOutsideWorkspace = toolInfo?.isOutsideWorkspace as boolean | undefined + const reason = toolInfo?.reason as string | undefined + const rawContent = toolInfo?.content as string | undefined + + // Get the display output (formatted by App.tsx) - already sanitized + const toolDisplayOutput = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : undefined + + // Sanitize raw content if present + const sanitizedRawContent = rawContent ? sanitizeContent(rawContent) : undefined + + // Format the header + const displayName = message.toolDisplayName || toolName + const headerText = `${icon} ${displayName}` + + return ( + + {/* Tool Header */} + + {headerText} + + + {/* Path indicator for file/directory operations */} + {path && ( + + + {category === "file" ? "file: " : category === "directory" ? "dir: " : "path: "} + + + {path} + + {isOutsideWorkspace && ( + + {" (outside workspace)"} + + )} + + )} + + {/* Reason/explanation if present */} + {reason && ( + + + {reason} + + + )} + + {/* Content display */} + {(toolDisplayOutput || sanitizedRawContent) && ( + + {(() => { + const contentToDisplay = toolDisplayOutput || sanitizedRawContent || "" + const { text, truncated, totalLines } = truncateContent(contentToDisplay, 15) + + return ( + <> + {text} + {truncated && ( + + {`... (${totalLines - 15} more lines)`} + + )} + + ) + })()} + + )} + + + + + + ) +} + interface ChatHistoryItemProps { message: TUIMessage } @@ -75,20 +290,8 @@ function ChatHistoryItem({ message }: ChatHistoryItemProps) { ) } - // Sanitize toolDisplayOutput if present, otherwise use already-sanitized content - const toolContent = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : content - - return ( - - - {`tool - ${message.toolDisplayName || message.toolName || "unknown"}`} - - - {toolContent} - - - - ) + // Use the improved ToolDisplay component + return } case "system": // System messages are typically rendered as Header, not here. diff --git a/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx index c2f9e6374b..0d50dd6408 100644 --- a/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx +++ b/apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx @@ -51,18 +51,24 @@ describe("ChatHistoryItem", () => { expect(output).toContain(" function foo() {}") // Double-indented }) - it("sanitizes tabs in tool messages", () => { + it("sanitizes tabs in tool messages with parsed content", () => { + // Tool messages parse JSON content to extract fields like 'content' const message: TUIMessage = { id: "4", role: "tool", - content: '{\n\t"key": "value"\n}', + content: JSON.stringify({ + tool: "read_file", + path: "test.js", + content: "function() {\n\treturn true;\n}", + }), toolName: "read_file", } const { lastFrame } = render() const output = lastFrame() - expect(output).toContain(' "key": "value"') + // The content inside the JSON should be sanitized + expect(output).toContain(" return true;") expect(output).not.toContain("\t") }) @@ -190,11 +196,11 @@ describe("ChatHistoryItem", () => { expect(output).toContain("Let me think...") }) - it("renders tool messages with tool name", () => { + it("renders tool messages with icon and tool display name", () => { const message: TUIMessage = { id: "4", role: "tool", - content: "Output", + content: JSON.stringify({ tool: "read_file", path: "test.txt", content: "Output text" }), toolName: "read_file", toolDisplayName: "Read File", } @@ -202,13 +208,66 @@ describe("ChatHistoryItem", () => { const { lastFrame } = render() const output = lastFrame() - expect(output).toContain("tool - Read File") - expect(output).toContain("Output") + // New format uses icon + display name + expect(output).toContain("📄 Read File") + expect(output).toContain("Output text") + }) + + it("renders tool messages with path indicator for file tools", () => { + const message: TUIMessage = { + id: "5", + role: "tool", + content: JSON.stringify({ tool: "read_file", path: "src/test.ts", content: "file content" }), + toolName: "read_file", + toolDisplayName: "Read File", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("file:") + expect(output).toContain("src/test.ts") + }) + + it("renders tool messages with directory path indicator for list tools", () => { + const message: TUIMessage = { + id: "6", + role: "tool", + content: JSON.stringify({ tool: "listFilesRecursive", path: "src/", content: "file1\nfile2" }), + toolName: "listFilesRecursive", + toolDisplayName: "List Files", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("dir:") + expect(output).toContain("src/") + }) + + it("shows outside workspace warning when applicable", () => { + const message: TUIMessage = { + id: "7", + role: "tool", + content: JSON.stringify({ + tool: "read_file", + path: "/etc/hosts", + isOutsideWorkspace: true, + content: "hosts file", + }), + toolName: "read_file", + toolDisplayName: "Read File", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("outside workspace") }) it("uses fallback content when message.content is empty", () => { const message: TUIMessage = { - id: "5", + id: "8", role: "assistant", content: "", } @@ -221,7 +280,7 @@ describe("ChatHistoryItem", () => { it("returns null for unknown role", () => { const message = { - id: "6", + id: "9", // eslint-disable-next-line @typescript-eslint/no-explicit-any role: "unknown" as any, content: "Test", @@ -230,5 +289,38 @@ describe("ChatHistoryItem", () => { const { lastFrame } = render() expect(lastFrame()).toBe("") }) + + it("renders command tools with command icon", () => { + const message: TUIMessage = { + id: "10", + role: "tool", + content: JSON.stringify({ tool: "execute_command" }), + toolName: "execute_command", + toolDisplayName: "Execute Command", + toolDisplayOutput: "command output", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("💻 Execute Command") + expect(output).toContain("command output") + }) + + it("renders search tools with search icon", () => { + const message: TUIMessage = { + id: "11", + role: "tool", + content: JSON.stringify({ tool: "search_files" }), + toolName: "search_files", + toolDisplayName: "Search Files", + toolDisplayOutput: "search results", + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("🔍 Search Files") + }) }) }) diff --git a/apps/cli/src/ui/components/autocomplete/index.ts b/apps/cli/src/ui/components/autocomplete/index.ts index 0d8621df1b..288ceb194c 100644 --- a/apps/cli/src/ui/components/autocomplete/index.ts +++ b/apps/cli/src/ui/components/autocomplete/index.ts @@ -59,4 +59,8 @@ export { type ModeTriggerConfig, createHelpTrigger, type HelpShortcutResult, + createHistoryTrigger, + toHistoryResult, + type HistoryResult, + type HistoryTriggerConfig, } from "./triggers/index.js" diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx new file mode 100644 index 0000000000..906280ab42 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.test.tsx @@ -0,0 +1,277 @@ +import { render } from "ink-testing-library" +import { describe, it, expect, vi } from "vitest" + +import { createHistoryTrigger, toHistoryResult, type HistoryResult } from "./HistoryTrigger.js" + +// Sample history items for testing +const mockHistoryItems: HistoryResult[] = [ + { + key: "task-1", + id: "task-1", + task: "Fix the login bug in the auth module", + ts: Date.now() - 1000 * 60 * 30, // 30 minutes ago + mode: "code", + status: "completed", + workspace: "/projects/my-app", + }, + { + key: "task-2", + id: "task-2", + task: "Add unit tests for the user service", + ts: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago + mode: "test", + status: "active", + workspace: "/projects/my-app", + }, + { + key: "task-3", + id: "task-3", + task: "Refactor the database queries for better performance", + ts: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago + mode: "architect", + status: "delegated", + workspace: "/projects/other-app", + }, +] + +describe("HistoryTrigger", () => { + describe("createHistoryTrigger", () => { + it("should detect # trigger at line start", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const result = trigger.detectTrigger("#") + expect(result).toEqual({ query: "", triggerIndex: 0 }) + }) + + it("should detect # trigger with query", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const result = trigger.detectTrigger("#login") + expect(result).toEqual({ query: "login", triggerIndex: 0 }) + }) + + it("should detect # trigger after whitespace", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const result = trigger.detectTrigger(" #") + expect(result).toEqual({ query: "", triggerIndex: 2 }) + }) + + it("should detect # trigger with query after whitespace", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const result = trigger.detectTrigger(" #fix") + expect(result).toEqual({ query: "fix", triggerIndex: 2 }) + }) + + it("should not detect # in middle of text", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + // The trigger position is "line-start", so it should only match at start + const result = trigger.detectTrigger("some text #") + expect(result).toBeNull() + }) + + it("should return all history items when query is empty, sorted by timestamp", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const results = trigger.search("") as HistoryResult[] + + // Should return all 3 items + expect(results.length).toBe(3) + // Should be sorted by timestamp (newest first) + expect(results[0]?.id).toBe("task-1") // 30 mins ago + expect(results[1]?.id).toBe("task-2") // 2 hours ago + expect(results[2]?.id).toBe("task-3") // 1 day ago + }) + + it("should filter history items by fuzzy search on task", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const results = trigger.search("login") as HistoryResult[] + expect(results.length).toBe(1) + expect(results[0]?.id).toBe("task-1") + expect(results[0]?.task).toContain("login") + }) + + it("should handle partial matching", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + // Fuzzy search for "unit" should match "Add unit tests for the user service" + const results = trigger.search("unit") as HistoryResult[] + expect(results.length).toBe(1) + expect(results[0]?.id).toBe("task-2") + }) + + it("should return empty array for non-matching query", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const results = trigger.search("xyznonexistent") as HistoryResult[] + expect(results.length).toBe(0) + }) + + it("should respect maxResults limit", () => { + const manyItems: HistoryResult[] = Array.from({ length: 20 }, (_, i) => ({ + key: `task-${i}`, + id: `task-${i}`, + task: `Task number ${i}`, + ts: Date.now() - i * 1000 * 60, + mode: "code", + })) + + const trigger = createHistoryTrigger({ + getHistory: () => manyItems, + maxResults: 5, + }) + + const results = trigger.search("") as HistoryResult[] + expect(results.length).toBe(5) + }) + + it("should use default maxResults of 15", () => { + const manyItems: HistoryResult[] = Array.from({ length: 20 }, (_, i) => ({ + key: `task-${i}`, + id: `task-${i}`, + task: `Task number ${i}`, + ts: Date.now() - i * 1000 * 60, + mode: "code", + })) + + const trigger = createHistoryTrigger({ + getHistory: () => manyItems, + }) + + const results = trigger.search("") as HistoryResult[] + expect(results.length).toBe(15) + }) + + it("should return empty string for replacement text", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const item = mockHistoryItems[0]! + const replacement = trigger.getReplacementText(item, "#login", 0) + expect(replacement).toBe("") + }) + + it("should render history items correctly", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const item = mockHistoryItems[0]! + const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + + const output = lastFrame() + // Should contain the task (possibly truncated) + expect(output).toContain("login") + // Should contain mode indicator + expect(output).toContain("[code]") + // Should contain status indicator (✓ for completed) + expect(output).toContain("✓") + }) + + it("should render active status with correct indicator", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const activeItem = mockHistoryItems[1]! // status: "active" + const { lastFrame } = render(trigger.renderItem(activeItem, false) as React.ReactElement) + + const output = lastFrame() + // Should contain the active status indicator (●) + expect(output).toContain("●") + }) + + it("should render delegated status with correct indicator", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const delegatedItem = mockHistoryItems[2]! // status: "delegated" + const { lastFrame } = render(trigger.renderItem(delegatedItem, false) as React.ReactElement) + + const output = lastFrame() + // Should contain the delegated status indicator (○) + expect(output).toContain("○") + }) + + it("should render selected items with different styling", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + const item = mockHistoryItems[0]! + const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement) + const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement) + + // Both should contain the task content + expect(unselectedFrame()).toContain("login") + expect(selectedFrame()).toContain("login") + }) + + it("should have correct trigger configuration", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + expect(trigger.id).toBe("history") + expect(trigger.triggerChar).toBe("#") + expect(trigger.position).toBe("line-start") + expect(trigger.emptyMessage).toBe("No task history found") + expect(trigger.debounceMs).toBe(100) + }) + + it("should not have consumeTrigger set (# character appears in input)", () => { + const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems }) + + // The # character should remain in the input like other triggers + expect(trigger.consumeTrigger).toBeUndefined() + }) + + it("should call getHistory when searching", () => { + const getHistoryMock = vi.fn(() => mockHistoryItems) + const trigger = createHistoryTrigger({ getHistory: getHistoryMock }) + + trigger.search("") + expect(getHistoryMock).toHaveBeenCalled() + + trigger.search("test") + expect(getHistoryMock).toHaveBeenCalledTimes(2) + }) + }) + + describe("toHistoryResult", () => { + it("should convert history item to HistoryResult", () => { + const item = { + id: "test-task-1", + task: "Test task description", + ts: 1704067200000, + totalCost: 0.05, + workspace: "/projects/test", + mode: "code", + status: "completed" as const, + } + + const result = toHistoryResult(item) + + expect(result.key).toBe("test-task-1") // key should be the task ID + expect(result.id).toBe("test-task-1") + expect(result.task).toBe("Test task description") + expect(result.ts).toBe(1704067200000) + expect(result.totalCost).toBe(0.05) + expect(result.workspace).toBe("/projects/test") + expect(result.mode).toBe("code") + expect(result.status).toBe("completed") + }) + + it("should handle optional fields", () => { + const minimalItem = { + id: "minimal-task", + task: "Minimal task", + ts: 1704067200000, + } + + const result = toHistoryResult(minimalItem) + + expect(result.key).toBe("minimal-task") + expect(result.id).toBe("minimal-task") + expect(result.task).toBe("Minimal task") + expect(result.ts).toBe(1704067200000) + expect(result.totalCost).toBeUndefined() + expect(result.workspace).toBeUndefined() + expect(result.mode).toBeUndefined() + expect(result.status).toBeUndefined() + }) + }) +}) diff --git a/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx new file mode 100644 index 0000000000..443fdfa979 --- /dev/null +++ b/apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx @@ -0,0 +1,193 @@ +import { Box, Text } from "ink" +import fuzzysort from "fuzzysort" + +import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js" + +/** + * History result type. + * Extends AutocompleteItem with task history properties. + */ +export interface HistoryResult extends AutocompleteItem { + /** Task ID */ + id: string + /** Task prompt/description */ + task: string + /** Timestamp when task was created */ + ts: number + /** Total cost of the task */ + totalCost?: number + /** Workspace path where task was run */ + workspace?: string + /** Mode the task was run in */ + mode?: string + /** Task status */ + status?: "active" | "completed" | "delegated" +} + +/** + * Props for creating a history trigger + */ +export interface HistoryTriggerConfig { + /** + * Get all available history items for filtering. + * Items are filtered locally using fuzzy search. + */ + getHistory: () => HistoryResult[] + /** + * Callback when a history item is selected. + * Used to resume the task. + */ + onSelect?: (item: HistoryResult) => void + /** + * Maximum number of results to show. + * @default 15 + */ + maxResults?: number +} + +/** + * Format a timestamp as a relative time string + */ +function formatRelativeTime(ts: number): string { + const now = Date.now() + const diff = now - ts + + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) { + return days === 1 ? "1 day ago" : `${days} days ago` + } + if (hours > 0) { + return hours === 1 ? "1 hour ago" : `${hours} hours ago` + } + if (minutes > 0) { + return minutes === 1 ? "1 min ago" : `${minutes} mins ago` + } + return "just now" +} + +/** + * Truncate text to a maximum length with ellipsis + */ +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text + } + return text.substring(0, maxLength - 1) + "…" +} + +/** + * Create a history trigger for # task history. + * + * This trigger activates when the user types # at the start of a line, + * and allows selecting from task history with local fuzzy filtering. + * + * @param config - Configuration for the trigger + * @returns AutocompleteTrigger for history + */ +export function createHistoryTrigger(config: HistoryTriggerConfig): AutocompleteTrigger { + const { getHistory, maxResults = 15 } = config + + return { + id: "history", + triggerChar: "#", + position: "line-start", + + detectTrigger: (lineText: string): TriggerDetectionResult | null => { + // Check if line starts with # (after optional whitespace) + const trimmed = lineText.trimStart() + + if (!trimmed.startsWith("#")) { + return null + } + + // Extract query after # + const query = trimmed.substring(1) + + // Calculate trigger index (position of # in original line) + const triggerIndex = lineText.length - trimmed.length + + return { query, triggerIndex } + }, + + search: (query: string): HistoryResult[] => { + const allHistory = getHistory() + + if (query.length === 0) { + // Show most recent items when just "#" is typed (sorted by timestamp, newest first) + return allHistory.sort((a, b) => b.ts - a.ts).slice(0, maxResults) + } + + // Fuzzy search by task description + const results = fuzzysort.go(query, allHistory, { + key: "task", + limit: maxResults, + threshold: -10000, // Be lenient with matching + }) + + return results.map((result) => result.obj) + }, + + renderItem: (item: HistoryResult, isSelected: boolean) => { + // Status indicator + const statusIcon = item.status === "completed" ? "✓" : item.status === "active" ? "●" : "○" + const statusColor = item.status === "completed" ? "green" : item.status === "active" ? "yellow" : "gray" + + // Mode indicator (if available) + const modeText = item.mode ? ` [${item.mode}]` : "" + + // Time ago + const timeAgo = formatRelativeTime(item.ts) + + // Truncate task to fit in picker + const truncatedTask = truncate(item.task.replace(/\n/g, " "), 50) + + return ( + + + {statusIcon} {truncatedTask} + {modeText} + • {timeAgo} + + + ) + }, + + getReplacementText: (_item: HistoryResult, _lineText: string, _triggerIndex: number): string => { + // Return empty string - we don't want to insert any text + // The actual task resumption is handled via the onSelect callback + return "" + }, + + emptyMessage: "No task history found", + debounceMs: 100, + } +} + +/** + * Convert HistoryItem from @roo-code/types to HistoryResult. + * Use this to adapt history items from the store to the trigger's expected type. + */ +export function toHistoryResult(item: { + id: string + task: string + ts: number + totalCost?: number + workspace?: string + mode?: string + status?: "active" | "completed" | "delegated" +}): HistoryResult { + return { + key: item.id, // Use task ID as the unique key + id: item.id, + task: item.task, + ts: item.ts, + totalCost: item.totalCost, + workspace: item.workspace, + mode: item.mode, + status: item.status, + } +} diff --git a/apps/cli/src/ui/components/autocomplete/triggers/index.ts b/apps/cli/src/ui/components/autocomplete/triggers/index.ts index 2a2f86c2e9..ee8c6c65b0 100644 --- a/apps/cli/src/ui/components/autocomplete/triggers/index.ts +++ b/apps/cli/src/ui/components/autocomplete/triggers/index.ts @@ -14,3 +14,10 @@ export { export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js" export { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js" + +export { + createHistoryTrigger, + toHistoryResult, + type HistoryResult, + type HistoryTriggerConfig, +} from "./HistoryTrigger.js" diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts index 9e67c69999..f0675d9a58 100644 --- a/apps/cli/src/ui/store.ts +++ b/apps/cli/src/ui/store.ts @@ -2,7 +2,14 @@ import { create } from "zustand" import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" -import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult, ModeResult } from "./types.js" +import type { + TUIMessage, + PendingAsk, + FileSearchResult, + SlashCommandResult, + ModeResult, + TaskHistoryItem, +} from "./types.js" /** * RouterModels type for context window lookup. @@ -33,6 +40,9 @@ interface CLIState { allSlashCommands: SlashCommandResult[] availableModes: ModeResult[] + // Task history (for resuming previous tasks) + taskHistory: TaskHistoryItem[] + // Current mode (updated reactively when mode changes) currentMode: string | null @@ -66,6 +76,9 @@ interface CLIActions { setAllSlashCommands: (commands: SlashCommandResult[]) => void setAvailableModes: (modes: ModeResult[]) => void + // Task history action + setTaskHistory: (history: TaskHistoryItem[]) => void + // Current mode action setCurrentMode: (mode: string | null) => void @@ -88,6 +101,7 @@ const initialState: CLIState = { fileSearchResults: [], allSlashCommands: [], availableModes: [], + taskHistory: [], currentMode: null, tokenUsage: null, routerModels: null, @@ -149,6 +163,7 @@ export const useCLIStore = create((set) => ({ setFileSearchResults: (results) => set({ fileSearchResults: results }), setAllSlashCommands: (commands) => set({ allSlashCommands: commands }), setAvailableModes: (modes) => set({ availableModes: modes }), + setTaskHistory: (history) => set({ taskHistory: history }), setCurrentMode: (mode) => set({ currentMode: mode }), setTokenUsage: (usage) => set({ tokenUsage: usage }), setRouterModels: (models) => set({ routerModels: models }), diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index a9e5ada1cb..f1146c6fd8 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -100,3 +100,27 @@ export interface ModeResult { description?: string icon?: string } + +/** + * Task history item for the CLI. + * Subset of HistoryItem from @roo-code/types with fields needed for display and resumption. + */ +export interface TaskHistoryItem { + /** Unique task ID */ + id: string + /** Task prompt/description */ + task: string + /** Timestamp when task was created */ + ts: number + /** Total cost of the task */ + totalCost?: number + /** Workspace path where task was run */ + workspace?: string + /** Mode the task was run in */ + mode?: string + /** Task status */ + status?: "active" | "completed" | "delegated" + /** Tokens consumed */ + tokensIn?: number + tokensOut?: number +} diff --git a/apps/cli/src/utils/pathUtils.test.ts b/apps/cli/src/utils/pathUtils.test.ts new file mode 100644 index 0000000000..a8576202da --- /dev/null +++ b/apps/cli/src/utils/pathUtils.test.ts @@ -0,0 +1,57 @@ +import { normalizePath, arePathsEqual } from "./pathUtils.js" + +describe("normalizePath", () => { + it("should remove trailing slashes", () => { + expect(normalizePath("/Users/test/project/")).toBe("/Users/test/project") + expect(normalizePath("/Users/test/project//")).toBe("/Users/test/project") + }) + + it("should handle paths without trailing slashes", () => { + expect(normalizePath("/Users/test/project")).toBe("/Users/test/project") + }) + + it("should normalize path separators", () => { + // path.normalize handles this + expect(normalizePath("/Users//test/project")).toBe("/Users/test/project") + }) +}) + +describe("arePathsEqual", () => { + it("should return true for identical paths", () => { + expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true) + }) + + it("should return true for paths differing only by trailing slash", () => { + expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true) + expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true) + }) + + it("should return false for undefined or empty paths", () => { + expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false) + expect(arePathsEqual("/Users/test/project", undefined)).toBe(false) + expect(arePathsEqual(undefined, undefined)).toBe(false) + expect(arePathsEqual("", "/Users/test/project")).toBe(false) + expect(arePathsEqual("/Users/test/project", "")).toBe(false) + }) + + it("should return false for different paths", () => { + expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false) + expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false) + }) + + // Case sensitivity behavior depends on platform + if (process.platform === "darwin" || process.platform === "win32") { + it("should be case-insensitive on macOS/Windows", () => { + expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true) + expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true) + }) + } else { + it("should be case-sensitive on Linux", () => { + expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false) + }) + } + + it("should handle paths with multiple trailing slashes", () => { + expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true) + }) +}) diff --git a/apps/cli/src/utils/pathUtils.ts b/apps/cli/src/utils/pathUtils.ts new file mode 100644 index 0000000000..ccaecd8081 --- /dev/null +++ b/apps/cli/src/utils/pathUtils.ts @@ -0,0 +1,35 @@ +import * as path from "path" + +/** + * Normalize a path by removing trailing slashes and converting separators. + * This handles cross-platform path comparison issues. + */ +export function normalizePath(p: string): string { + // Remove trailing slashes + let normalized = p.replace(/[/\\]+$/, "") + // Convert to consistent separators using path.normalize + normalized = path.normalize(normalized) + return normalized +} + +/** + * Compare two paths for equality, handling: + * - Trailing slashes + * - Path separator differences + * - Case sensitivity (case-insensitive on Windows/macOS) + */ +export function arePathsEqual(path1?: string, path2?: string): boolean { + if (!path1 || !path2) { + return false + } + + const normalizedPath1 = normalizePath(path1) + const normalizedPath2 = normalizePath(path2) + + // On Windows and macOS, file paths are case-insensitive + if (process.platform === "win32" || process.platform === "darwin") { + return normalizedPath1.toLowerCase() === normalizedPath2.toLowerCase() + } + + return normalizedPath1 === normalizedPath2 +} diff --git a/apps/cli/src/utils/toolInspectorLogger.ts b/apps/cli/src/utils/toolInspectorLogger.ts new file mode 100644 index 0000000000..d52d6d44a6 --- /dev/null +++ b/apps/cli/src/utils/toolInspectorLogger.ts @@ -0,0 +1,66 @@ +/** + * Tool Inspector Logger + * + * A dedicated logger for inspecting tool use payloads in the CLI. + * This writes to ~/.roo/cli-tool-inspector.log, separate from the general + * debug log to avoid noise when specifically investigating tool shapes. + * + * Usage: + * import { toolInspectorLog } from "../utils/toolInspectorLogger.js" + * + * toolInspectorLog("tool:received", { toolName, payload }) + */ + +import * as fs from "fs" +import * as path from "path" +import * as os from "os" + +const TOOL_INSPECTOR_LOG_PATH = path.join(os.homedir(), ".roo", "cli-tool-inspector.log") + +/** + * Log a tool inspection entry to the dedicated log file. + * Writes timestamped JSON entries to ~/.roo/cli-tool-inspector.log + */ +export function toolInspectorLog(event: string, data?: unknown): void { + try { + const logDir = path.dirname(TOOL_INSPECTOR_LOG_PATH) + + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + + const timestamp = new Date().toISOString() + + const entry = { + timestamp, + event, + ...(data !== undefined && { data }), + } + + // Write as formatted JSON for easier inspection + fs.appendFileSync(TOOL_INSPECTOR_LOG_PATH, JSON.stringify(entry, null, 2) + "\n---\n") + } catch { + // NO-OP - don't let logging errors break functionality + } +} + +/** + * Clear the tool inspector log file. + * Useful for starting a fresh inspection session. + */ +export function clearToolInspectorLog(): void { + try { + if (fs.existsSync(TOOL_INSPECTOR_LOG_PATH)) { + fs.unlinkSync(TOOL_INSPECTOR_LOG_PATH) + } + } catch { + // NO-OP + } +} + +/** + * Get the path to the tool inspector log file. + */ +export function getToolInspectorLogPath(): string { + return TOOL_INSPECTOR_LOG_PATH +}