Fix history picker bug, fix mode switcher bug

This commit is contained in:
cte 2026-01-07 15:15:04 -08:00
parent 5b5d796d47
commit 4a4156085b
14 changed files with 1406 additions and 73 deletions

View file

@ -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<Promise<void>>(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<string, unknown>).currentMode = "architect"
// Create settings object to be modified
const settings: Record<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown>).currentMode = "debug"
const settings: Record<string, unknown> = {}
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<string, unknown>).currentMode = null
// Also clear options.mode
const options = getPrivate<ExtensionHostOptions>(host, "options")
options.mode = ""
const settings: Record<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
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<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("ask")
})
})
describe("ephemeral mode", () => {
describe("constructor", () => {
it("should store ephemeral option", () => {

View file

@ -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<void> {
// 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<void>((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<string, unknown>
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))
})
}

View file

@ -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<Set<string>>(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<any>[] => {
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<string, unknown>
// 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<string, unknown>
// 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<Record<string, unknown>> | 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

View file

@ -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<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
}
/**
* 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<ToolCategory, string> = {
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<string, unknown> | 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 (
<Box flexDirection="column" paddingX={1}>
{/* Tool Header */}
<Text bold color={categoryColor}>
{headerText}
</Text>
{/* Path indicator for file/directory operations */}
{path && (
<Box marginLeft={2}>
<Text color={theme.dimText}>
{category === "file" ? "file: " : category === "directory" ? "dir: " : "path: "}
</Text>
<Text color={theme.text} bold>
{path}
</Text>
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" (outside workspace)"}
</Text>
)}
</Box>
)}
{/* Reason/explanation if present */}
{reason && (
<Box marginLeft={2}>
<Text color={theme.dimText} italic>
{reason}
</Text>
</Box>
)}
{/* Content display */}
{(toolDisplayOutput || sanitizedRawContent) && (
<Box flexDirection="column" marginLeft={2} marginTop={0}>
{(() => {
const contentToDisplay = toolDisplayOutput || sanitizedRawContent || ""
const { text, truncated, totalLines } = truncateContent(contentToDisplay, 15)
return (
<>
<Text color={theme.toolText}>{text}</Text>
{truncated && (
<Text color={theme.dimText} dimColor>
{`... (${totalLines - 15} more lines)`}
</Text>
)}
</>
)
})()}
</Box>
)}
<Text>
<Newline />
</Text>
</Box>
)
}
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 (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.toolHeader}>
{`tool - ${message.toolDisplayName || message.toolName || "unknown"}`}
</Text>
<Text color={theme.toolText}>
{toolContent}
<Newline />
</Text>
</Box>
)
// Use the improved ToolDisplay component
return <ToolDisplay message={message} />
}
case "system":
// System messages are typically rendered as Header, not here.

View file

@ -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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
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(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("🔍 Search Files")
})
})
})

View file

@ -59,4 +59,8 @@ export {
type ModeTriggerConfig,
createHelpTrigger,
type HelpShortcutResult,
createHistoryTrigger,
toHistoryResult,
type HistoryResult,
type HistoryTriggerConfig,
} from "./triggers/index.js"

View file

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

View file

@ -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<HistoryResult> {
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 (
<Box paddingLeft={2} flexDirection="row">
<Text color={isSelected ? "cyan" : undefined}>
<Text color={statusColor}>{statusIcon}</Text> {truncatedTask}
<Text dimColor>{modeText}</Text>
<Text dimColor> • {timeAgo}</Text>
</Text>
</Box>
)
},
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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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