mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-09 03:17:58 +00:00
Fix history picker bug, fix mode switcher bug
This commit is contained in:
parent
5b5d796d47
commit
4a4156085b
14 changed files with 1406 additions and 73 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,4 +59,8 @@ export {
|
|||
type ModeTriggerConfig,
|
||||
createHelpTrigger,
|
||||
type HelpShortcutResult,
|
||||
createHistoryTrigger,
|
||||
toHistoryResult,
|
||||
type HistoryResult,
|
||||
type HistoryTriggerConfig,
|
||||
} from "./triggers/index.js"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
57
apps/cli/src/utils/pathUtils.test.ts
Normal file
57
apps/cli/src/utils/pathUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
35
apps/cli/src/utils/pathUtils.ts
Normal file
35
apps/cli/src/utils/pathUtils.ts
Normal 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
|
||||
}
|
||||
66
apps/cli/src/utils/toolInspectorLogger.ts
Normal file
66
apps/cli/src/utils/toolInspectorLogger.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue