diff --git a/apps/cli/package.json b/apps/cli/package.json index f4c4a3bcb5..0a9533b013 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,19 +14,25 @@ "check-types": "tsc --noEmit", "test": "vitest run", "build": "tsup", + "dev": "tsup --watch", "start": "node dist/index.js", "clean": "rimraf dist .turbo" }, "dependencies": { + "@inkjs/ui": "^2.0.0", "@roo-code/types": "workspace:^", "@roo-code/vscode-shim": "workspace:^", "@vscode/ripgrep": "^1.15.9", - "commander": "^12.1.0" + "commander": "^12.1.0", + "ink": "^6.6.0", + "react": "^19.1.0", + "zustand": "^5.0.0" }, "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@types/node": "^24.1.0", + "@types/react": "^19.1.6", "rimraf": "^6.0.1", "tsup": "^8.4.0", "typescript": "5.8.3", diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts index 509ad27d1e..b796eb1559 100644 --- a/apps/cli/src/__tests__/extension-host.test.ts +++ b/apps/cli/src/__tests__/extension-host.test.ts @@ -1161,4 +1161,113 @@ describe("ExtensionHost", () => { vi.useRealTimers() }) }) + + describe("handleStateMessage - mode change detection", () => { + let host: ExtensionHost + let sendToExtensionSpy: ReturnType + + beforeEach(() => { + host = createTestHost({ + mode: "code", + apiProvider: "anthropic", + apiKey: "test-key", + model: "test-model", + }) + // Mock process.stdout.write which is used by output() + vi.spyOn(process.stdout, "write").mockImplementation(() => true) + sendToExtensionSpy = vi.spyOn(host, "sendToExtension") + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should re-apply API configuration when mode changes in state", () => { + // First state update establishes current mode + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) + sendToExtensionSpy.mockClear() + + // Second state update with different mode should trigger re-apply + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } }) + + // Should have sent updateSettings with the API configuration + expect(sendToExtensionSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "test-model", + }), + }), + ) + }) + + it("should not re-apply API configuration when mode stays the same", () => { + // First state update establishes current mode + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) + sendToExtensionSpy.mockClear() + + // Second state update with same mode should not trigger re-apply + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) + + // Should not have sent updateSettings + expect(sendToExtensionSpy).not.toHaveBeenCalled() + }) + + it("should re-apply API configuration without apiKey when mode changes", () => { + // Create host without apiKey - API configuration should still be sent + // to preserve provider/model settings across mode switches + const hostNoKey = createTestHost({ + mode: "code", + apiProvider: "anthropic", + model: "test-model", + }) + const sendSpy = vi.spyOn(hostNoKey, "sendToExtension") + + // First state update establishes current mode + callPrivate(hostNoKey, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) + sendSpy.mockClear() + + // Second state update with different mode + callPrivate(hostNoKey, "handleStateMessage", { + type: "state", + state: { mode: "architect", clineMessages: [] }, + }) + + // Should have sent updateSettings with provider and model (but no apiKey) + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + apiProvider: "anthropic", + apiModelId: "test-model", + }), + }), + ) + // Verify apiKey is NOT in the config + const call = sendSpy.mock.calls[0]?.[0] as { updatedSettings: { apiKey?: string } } | undefined + expect(call?.updatedSettings.apiKey).toBeUndefined() + }) + + it("should track current mode across multiple changes", () => { + // Start with code mode + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } }) + sendToExtensionSpy.mockClear() + + // Change to architect + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } }) + expect(sendToExtensionSpy).toHaveBeenCalledTimes(1) + sendToExtensionSpy.mockClear() + + // Change to debug + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) + expect(sendToExtensionSpy).toHaveBeenCalledTimes(1) + sendToExtensionSpy.mockClear() + + // Stay on debug + callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } }) + expect(sendToExtensionSpy).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/cli/src/__tests__/historyStorage.test.ts b/apps/cli/src/__tests__/historyStorage.test.ts new file mode 100644 index 0000000000..225178bca2 --- /dev/null +++ b/apps/cli/src/__tests__/historyStorage.test.ts @@ -0,0 +1,238 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import { + getHistoryFilePath, + loadHistory, + saveHistory, + addToHistory, + MAX_HISTORY_ENTRIES, +} from "../utils/historyStorage.js" + +vi.mock("fs/promises") + +vi.mock("os", () => ({ + homedir: vi.fn(() => "/home/testuser"), +})) + +describe("historyStorage", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe("getHistoryFilePath", () => { + it("should return the correct path to cli-history.json", () => { + const result = getHistoryFilePath() + expect(result).toBe(path.join("/home/testuser", ".roo", "cli-history.json")) + }) + }) + + describe("loadHistory", () => { + it("should return empty array when file does not exist", async () => { + const error = new Error("ENOENT") as NodeJS.ErrnoException + error.code = "ENOENT" + vi.mocked(fs.readFile).mockRejectedValue(error) + + const result = await loadHistory() + + expect(result).toEqual([]) + }) + + it("should return entries from valid JSON file", async () => { + const mockData = { + version: 1, + entries: ["first command", "second command", "third command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual(["first command", "second command", "third command"]) + }) + + it("should return empty array for invalid JSON", async () => { + vi.mocked(fs.readFile).mockResolvedValue("not valid json") + + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await loadHistory() + + expect(result).toEqual([]) + consoleSpy.mockRestore() + }) + + it("should filter out non-string entries", async () => { + const mockData = { + version: 1, + entries: ["valid", 123, "also valid", null, ""], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual(["valid", "also valid"]) + }) + + it("should return empty array when entries is not an array", async () => { + const mockData = { + version: 1, + entries: "not an array", + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await loadHistory() + + expect(result).toEqual([]) + }) + }) + + describe("saveHistory", () => { + it("should create directory and save history", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + await saveHistory(["command1", "command2"]) + + expect(fs.mkdir).toHaveBeenCalledWith(path.join("/home/testuser", ".roo"), { recursive: true }) + expect(fs.writeFile).toHaveBeenCalled() + + // Verify the content written + const writeCall = vi.mocked(fs.writeFile).mock.calls[0] + const writtenContent = JSON.parse(writeCall?.[1] as string) + expect(writtenContent.version).toBe(1) + expect(writtenContent.entries).toEqual(["command1", "command2"]) + }) + + it("should trim entries to MAX_HISTORY_ENTRIES", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + // Create array larger than MAX_HISTORY_ENTRIES + const manyEntries = Array.from({ length: MAX_HISTORY_ENTRIES + 100 }, (_, i) => `command${i}`) + + await saveHistory(manyEntries) + + const writeCall = vi.mocked(fs.writeFile).mock.calls[0] + const writtenContent = JSON.parse(writeCall?.[1] as string) + expect(writtenContent.entries.length).toBe(MAX_HISTORY_ENTRIES) + // Should keep the most recent entries (last 500) + expect(writtenContent.entries[0]).toBe(`command100`) + expect(writtenContent.entries[MAX_HISTORY_ENTRIES - 1]).toBe(`command${MAX_HISTORY_ENTRIES + 99}`) + }) + + it("should handle directory already exists error", async () => { + const error = new Error("EEXIST") as NodeJS.ErrnoException + error.code = "EEXIST" + vi.mocked(fs.mkdir).mockRejectedValue(error) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + // Should not throw + await expect(saveHistory(["command"])).resolves.not.toThrow() + }) + + it("should log warning on write error but not throw", async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied")) + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(saveHistory(["command"])).resolves.not.toThrow() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Could not save CLI history"), + expect.any(String), + ) + + consoleSpy.mockRestore() + }) + }) + + describe("addToHistory", () => { + it("should add new entry to history", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory("new command") + + expect(result).toEqual(["existing command", "new command"]) + }) + + it("should not add empty strings", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory("") + + expect(result).toEqual(["existing command"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should not add whitespace-only strings", async () => { + const mockData = { + version: 1, + entries: ["existing command"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory(" ") + + expect(result).toEqual(["existing command"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should not add consecutive duplicates", async () => { + const mockData = { + version: 1, + entries: ["first", "second"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + + const result = await addToHistory("second") + + expect(result).toEqual(["first", "second"]) + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should add non-consecutive duplicates", async () => { + const mockData = { + version: 1, + entries: ["first", "second"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory("first") + + expect(result).toEqual(["first", "second", "first"]) + }) + + it("should trim whitespace from entry before adding", async () => { + const mockData = { + version: 1, + entries: ["existing"], + } + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData)) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + const result = await addToHistory(" new command ") + + expect(result).toEqual(["existing", "new command"]) + }) + }) + + describe("MAX_HISTORY_ENTRIES", () => { + it("should be 500", () => { + expect(MAX_HISTORY_ENTRIES).toBe(500) + }) + }) +}) diff --git a/apps/cli/src/__tests__/useInputHistory.test.ts b/apps/cli/src/__tests__/useInputHistory.test.ts new file mode 100644 index 0000000000..d6be84275d --- /dev/null +++ b/apps/cli/src/__tests__/useInputHistory.test.ts @@ -0,0 +1,171 @@ +import * as historyStorage from "../utils/historyStorage.js" + +vi.mock("../utils/historyStorage.js") + +// Track state and callbacks for testing. +let mockState: Record = {} +let mockInputHandler: ((input: string, key: { upArrow: boolean; downArrow: boolean }) => void) | null = null +let effectCallbacks: Array<() => void | (() => void)> = [] + +vi.mock("react", () => ({ + useState: vi.fn((initial: unknown) => { + const key = `state_${Object.keys(mockState).length}` + if (!(key in mockState)) { + mockState[key] = initial + } + return [ + mockState[key], + (newValue: unknown) => { + if (typeof newValue === "function") { + mockState[key] = (newValue as (prev: unknown) => unknown)(mockState[key]) + } else { + mockState[key] = newValue + } + }, + ] + }), + useEffect: vi.fn((callback: () => void | (() => void)) => { + effectCallbacks.push(callback) + }), + useCallback: vi.fn((callback: unknown) => callback), + useRef: vi.fn((initial: unknown) => ({ current: initial })), +})) + +vi.mock("ink", () => ({ + useInput: vi.fn( + ( + handler: (input: string, key: { upArrow: boolean; downArrow: boolean }) => void, + _options?: { isActive?: boolean }, + ) => { + mockInputHandler = handler + }, + ), +})) + +describe("useInputHistory", () => { + beforeEach(() => { + vi.resetAllMocks() + mockState = {} + mockInputHandler = null + effectCallbacks = [] + + // Default mock for loadHistory + vi.mocked(historyStorage.loadHistory).mockResolvedValue([]) + vi.mocked(historyStorage.addToHistory).mockImplementation(async (entry) => [entry]) + }) + + describe("historyStorage functions", () => { + it("loadHistory should be called when hook effect runs", async () => { + vi.mocked(historyStorage.loadHistory).mockResolvedValue(["entry1", "entry2"]) + + // Import the hook (this triggers the module initialization) + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + useInputHistory() + + // Run the effect callbacks + for (const cb of effectCallbacks) { + cb() + } + + expect(historyStorage.loadHistory).toHaveBeenCalled() + }) + + it("addToHistory should be called with trimmed entry", async () => { + vi.mocked(historyStorage.addToHistory).mockResolvedValue(["new entry"]) + + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + await result.addEntry(" new entry ") + + expect(historyStorage.addToHistory).toHaveBeenCalledWith("new entry") + }) + + it("addToHistory should not be called for empty entries", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + await result.addEntry("") + + expect(historyStorage.addToHistory).not.toHaveBeenCalled() + }) + + it("addToHistory should not be called for whitespace-only entries", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + await result.addEntry(" ") + + expect(historyStorage.addToHistory).not.toHaveBeenCalled() + }) + }) + + describe("navigation logic", () => { + it("should have initial state with no history value", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + // Initial state should have null history value (not browsing) + expect(result.historyValue).toBeNull() + expect(result.isBrowsing).toBe(false) + }) + + it("should register input handler with ink useInput", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + useInputHistory() + + expect(mockInputHandler).not.toBeNull() + }) + }) + + describe("resetBrowsing", () => { + it("should be a function", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + expect(typeof result.resetBrowsing).toBe("function") + }) + }) + + describe("return value structure", () => { + it("should return the expected interface", async () => { + const { useInputHistory } = await import("../ui/hooks/useInputHistory.js") + const result = useInputHistory() + + expect(result).toHaveProperty("addEntry") + expect(result).toHaveProperty("historyValue") + expect(result).toHaveProperty("isBrowsing") + expect(result).toHaveProperty("resetBrowsing") + expect(result).toHaveProperty("history") + expect(result).toHaveProperty("draft") + + expect(typeof result.addEntry).toBe("function") + expect(typeof result.resetBrowsing).toBe("function") + expect(Array.isArray(result.history)).toBe(true) + }) + }) +}) + +describe("historyStorage integration", () => { + // Test the actual historyStorage functions directly + // These are more reliable than hook tests with mocked React + + beforeEach(() => { + vi.resetAllMocks() + }) + + it("MAX_HISTORY_ENTRIES should be 500", async () => { + const { MAX_HISTORY_ENTRIES } = await import("../utils/historyStorage.js") + expect(MAX_HISTORY_ENTRIES).toBe(500) + }) + + it("getHistoryFilePath should return path in ~/.roo directory", async () => { + // Un-mock for this test + vi.doUnmock("../utils/historyStorage.js") + const { getHistoryFilePath } = await import("../utils/historyStorage.js") + + const path = getHistoryFilePath() + expect(path).toContain(".roo") + expect(path).toContain("cli-history.json") + }) +}) diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts index 3396386924..aeaa8a65d9 100644 --- a/apps/cli/src/extension-host.ts +++ b/apps/cli/src/extension-host.ts @@ -34,6 +34,11 @@ export interface ExtensionHostOptions { verbose?: boolean quiet?: boolean nonInteractive?: boolean + /** + * When true, completely disables all direct stdout/stderr output. + * Use this when running in TUI mode where Ink controls the terminal. + */ + disableOutput?: boolean } interface ExtensionModule { @@ -86,9 +91,14 @@ export class ExtensionHost extends EventEmitter { // Track if we're currently streaming a message (to manage newlines) private currentlyStreamingTs: number | null = null + // Track the current mode to detect mode changes + private currentMode: string | null = null + constructor(options: ExtensionHostOptions) { super() this.options = options + // Initialize currentMode from options to track mode changes + this.currentMode = options.mode || null } private log(...args: unknown[]): void { @@ -332,13 +342,37 @@ export class ExtensionHost extends EventEmitter { setRuntimeConfigValues("roo-cline", settings as Record) } + /** + * Get API key from environment variable based on provider + */ + private getApiKeyFromEnv(provider: string): string | undefined { + const envVarMap: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + "openai-native": "OPENAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + google: "GOOGLE_API_KEY", + gemini: "GOOGLE_API_KEY", + bedrock: "AWS_ACCESS_KEY_ID", + ollama: "OLLAMA_API_KEY", + mistral: "MISTRAL_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + xai: "XAI_API_KEY", + groq: "GROQ_API_KEY", + } + const envVar = envVarMap[provider.toLowerCase()] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY` + return process.env[envVar] + } + /** * Build the provider-specific API configuration * Each provider uses different field names for API key and model + * Falls back to environment variables for API keys when not explicitly passed */ private buildApiConfiguration(): RooCodeSettings { const provider = this.options.apiProvider || "anthropic" - const apiKey = this.options.apiKey + // Try explicit API key first, then fall back to environment variable + const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider) const model = this.options.model // Base config with provider. @@ -530,7 +564,6 @@ export class ExtensionHost extends EventEmitter { alwaysAllowSubtasks: true, alwaysAllowExecute: true, alwaysAllowFollowupQuestions: true, - // Allow all commands with wildcard (required for command auto-approval). allowedCommands: ["*"], commandExecutionTimeout: 20, } @@ -540,16 +573,21 @@ export class ExtensionHost extends EventEmitter { await new Promise((resolve) => setTimeout(resolve, 100)) } else { this.log("Interactive mode: user will be prompted for approvals...") - const settings: RooCodeSettings = { autoApprovalEnabled: false } + + const settings: RooCodeSettings = { + autoApprovalEnabled: false, + } + this.applyRuntimeSettings(settings) this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) await new Promise((resolve) => setTimeout(resolve, 100)) } - if (this.options.apiKey) { - this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() }) - await new Promise((resolve) => setTimeout(resolve, 100)) - } + // Always send API configuration - it may include API key from environment variables + const apiConfig = this.buildApiConfiguration() + this.log("Sending initial API configuration:", JSON.stringify(apiConfig)) + this.sendToExtension({ type: "updateSettings", updatedSettings: apiConfig }) + await new Promise((resolve) => setTimeout(resolve, 100)) this.sendToExtension({ type: "newTask", text: prompt }) await this.waitForCompletion() @@ -608,6 +646,10 @@ export class ExtensionHost extends EventEmitter { * Use this for all user-facing output instead of console.log */ private output(...args: unknown[]): void { + // In TUI mode, don't write directly to stdout - let Ink handle rendering + if (this.options.disableOutput) { + return + } const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") process.stdout.write(text + "\n") } @@ -617,6 +659,10 @@ export class ExtensionHost extends EventEmitter { * Use this for all user-facing errors instead of console.error */ private outputError(...args: unknown[]): void { + // In TUI mode, don't write directly to stderr - let Ink handle rendering + if (this.options.disableOutput) { + return + } const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") process.stderr.write(text + "\n") } @@ -628,6 +674,24 @@ export class ExtensionHost extends EventEmitter { const state = msg.state as Record | undefined if (!state) return + // Detect mode changes and re-apply API configuration + // This preserves the CLI-provided provider/model settings across mode switches + const newMode = state.mode as string | undefined + if (this.options.verbose) { + this.log(`State update: mode=${newMode}, currentMode=${this.currentMode}`) + } + if (newMode && this.currentMode !== null && this.currentMode !== newMode) { + const apiConfig = this.buildApiConfiguration() + this.log(`Mode changed from ${this.currentMode} to ${newMode}, re-applying API configuration...`) + if (this.options.verbose) { + this.log(`API config: ${JSON.stringify(apiConfig)}`) + } + this.sendToExtension({ type: "updateSettings", updatedSettings: apiConfig }) + } + if (newMode) { + this.currentMode = newMode + } + const clineMessages = state.clineMessages as Array> | undefined if (clineMessages && clineMessages.length > 0) { @@ -695,6 +759,10 @@ export class ExtensionHost extends EventEmitter { * Write streaming output directly to stdout (bypassing quiet mode if needed) */ private writeStream(text: string): void { + // In TUI mode, don't write directly to stdout - let Ink handle rendering + if (this.options.disableOutput) { + return + } process.stdout.write(text) } @@ -890,6 +958,12 @@ export class ExtensionHost extends EventEmitter { return } + // In TUI mode (disableOutput), don't handle asks here - let the TUI handle them + // The TUI listens to the same extensionWebviewMessage events and renders its own UI + if (this.options.disableOutput) { + return + } + // Interactive mode - prompt user for input this.handleAskMessageInteractive(ts, ask, text) } @@ -944,6 +1018,16 @@ export class ExtensionHost extends EventEmitter { } this.output(` ${key}: ${displayValue}`) } + + // Proactively send API config when switchMode tool is detected + // This helps preserve provider/model settings across mode switches + if (toolName === "switchMode") { + this.log("switchMode tool detected, proactively sending API configuration...") + this.sendToExtension({ + type: "updateSettings", + updatedSettings: this.buildApiConfiguration(), + }) + } } catch { this.output("\n[tool]", text) } @@ -1328,6 +1412,8 @@ export class ExtensionHost extends EventEmitter { try { const approved = await this.promptForYesNo("Approve this action? (y/n): ") this.sendApprovalResponse(approved) + // Note: Mode switch detection and API config re-application is handled in handleStateMessage + // This works for both interactive and non-interactive (auto-approved) modes } catch { this.output("[Defaulting to: no]") this.sendApprovalResponse(false) diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 15a2786ef4..40f7084e25 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -6,6 +6,7 @@ import { Command } from "commander" import fs from "fs" import path from "path" import { fileURLToPath } from "url" +import { createElement } from "react" import { type ProviderName, @@ -33,7 +34,7 @@ const program = new Command() program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0") program - .argument("", "The prompt/task to execute") + .argument("[prompt]", "The prompt/task to execute (optional in TUI mode)") .option("-w, --workspace ", "Workspace path to operate in", process.cwd()) .option("-e, --extension ", "Path to the extension bundle directory") .option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false) @@ -49,9 +50,10 @@ program "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", DEFAULTS.reasoningEffort, ) + .option("--no-tui", "Disable TUI, use plain text output") .action( async ( - prompt: string, + prompt: string | undefined, options: { workspace: string extension?: string @@ -64,6 +66,7 @@ program model?: string mode?: string reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled" + tui: boolean }, ) => { // Default is quiet mode - suppress VSCode shim logs unless verbose @@ -106,57 +109,145 @@ program process.exit(1) } - console.log(`[CLI] Mode: ${options.mode || "default"}`) - console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`) - console.log(`[CLI] Provider: ${options.provider}`) - console.log(`[CLI] Model: ${options.model || "default"}`) - console.log(`[CLI] Workspace: ${workspacePath}`) + // TUI is enabled by default, disabled with --no-tui + // TUI requires raw mode support (proper TTY for stdin and stdout) + const canUseTui = process.stdin.isTTY && process.stdout.isTTY + const useTui = options.tui && canUseTui - const host = new ExtensionHost({ - mode: options.mode || DEFAULTS.mode, - reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort, - apiProvider: options.provider, - apiKey, - model: options.model || DEFAULTS.model, - workspacePath, - extensionPath: path.resolve(extensionPath), - verbose: options.debug, - quiet: !options.verbose && !options.debug, - nonInteractive: options.yes, - }) + if (options.tui && !canUseTui) { + console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode") + } - // Handle SIGINT (Ctrl+C) - process.on("SIGINT", async () => { - console.log("\n[CLI] Received SIGINT, shutting down...") - await host.dispose() - process.exit(130) - }) - - // Handle SIGTERM - process.on("SIGTERM", async () => { - console.log("\n[CLI] Received SIGTERM, shutting down...") - await host.dispose() - process.exit(143) - }) - - try { - await host.activate() - await host.runTask(prompt) - await host.dispose() - - if (options.exitOnComplete) { - process.exit(0) - } - } catch (error) { - console.error("[CLI] Error:", error instanceof Error ? error.message : String(error)) - - if (options.debug && error instanceof Error) { - console.error(error.stack) - } - - await host.dispose() + // In plain text mode, prompt is required + if (!useTui && !prompt) { + console.error("[CLI] Error: prompt is required in plain text mode") + console.error("[CLI] Usage: roo [options]") + console.error("[CLI] Use TUI mode (without --no-tui) for interactive input") process.exit(1) } + + if (useTui) { + // TUI Mode - render Ink application + try { + // Clear screen before Ink starts + process.stdout.write("\x1B[2J\x1B[0;0H") + + const { render } = await import("ink") + const { App } = await import("./ui/App.js") + + // Create extension host factory for dependency injection + const createExtensionHost = (opts: { + mode: string + reasoningEffort?: string + apiProvider: string + apiKey: string + model: string + workspacePath: string + extensionPath: string + verbose: boolean + quiet: boolean + nonInteractive: boolean + disableOutput: boolean + }) => { + return new ExtensionHost({ + mode: opts.mode, + reasoningEffort: + opts.reasoningEffort === "unspecified" + ? undefined + : (opts.reasoningEffort as ReasoningEffortExtended | "disabled" | undefined), + apiProvider: opts.apiProvider as ProviderName, + apiKey: opts.apiKey, + model: opts.model, + workspacePath: opts.workspacePath, + extensionPath: opts.extensionPath, + verbose: opts.verbose, + quiet: opts.quiet, + nonInteractive: opts.nonInteractive, + disableOutput: opts.disableOutput, + }) + } + + render( + createElement(App, { + initialPrompt: prompt || "", // Empty string if no prompt - user will type in TUI + workspacePath: workspacePath, + extensionPath: path.resolve(extensionPath), + apiProvider: options.provider, + apiKey: apiKey, + model: options.model || DEFAULTS.model, + mode: options.mode || DEFAULTS.mode, + nonInteractive: options.yes, + verbose: options.verbose, + debug: options.debug, + exitOnComplete: options.exitOnComplete, + reasoningEffort: options.reasoningEffort, + createExtensionHost: createExtensionHost, + }), + { + exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit + }, + ) + } catch (error) { + console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error)) + if (options.debug && error instanceof Error) { + console.error(error.stack) + } + process.exit(1) + } + } else { + // Plain text mode (existing behavior) + console.log(`[CLI] Mode: ${options.mode || "default"}`) + console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`) + console.log(`[CLI] Provider: ${options.provider}`) + console.log(`[CLI] Model: ${options.model || "default"}`) + console.log(`[CLI] Workspace: ${workspacePath}`) + + const host = new ExtensionHost({ + mode: options.mode || DEFAULTS.mode, + reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort, + apiProvider: options.provider, + apiKey, + model: options.model || DEFAULTS.model, + workspacePath, + extensionPath: path.resolve(extensionPath), + verbose: options.debug, + quiet: !options.verbose && !options.debug, + nonInteractive: options.yes, + }) + + // Handle SIGINT (Ctrl+C) + process.on("SIGINT", async () => { + console.log("\n[CLI] Received SIGINT, shutting down...") + await host.dispose() + process.exit(130) + }) + + // Handle SIGTERM + process.on("SIGTERM", async () => { + console.log("\n[CLI] Received SIGTERM, shutting down...") + await host.dispose() + process.exit(143) + }) + + try { + await host.activate() + await host.runTask(prompt!) // prompt is guaranteed non-null in plain text mode + await host.dispose() + + if (options.exitOnComplete) { + process.exit(0) + } + } catch (error) { + console.error("[CLI] Error:", error instanceof Error ? error.message : String(error)) + + if (options.debug && error instanceof Error) { + console.error(error.stack) + } + + await host.dispose() + process.exit(1) + } + } }, ) diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx new file mode 100644 index 0000000000..dc92f6e542 --- /dev/null +++ b/apps/cli/src/ui/App.tsx @@ -0,0 +1,929 @@ +import { Box, Text, useApp, useInput } from "ink" +import { TextInput, Select } from "@inkjs/ui" +import { useState, useEffect, useCallback, useRef, useMemo } from "react" +import { EventEmitter } from "events" +import { randomUUID } from "crypto" + +import { useCLIStore } from "./store.js" +import Header from "./components/Header.js" +import ChatHistoryItem from "./components/ChatHistoryItem.js" +import LoadingText from "./components/LoadingText.js" +import { HistoryTextInput } from "./components/HistoryTextInput.js" +import { useTerminalSize } from "./hooks/useTerminalSize.js" +import * as theme from "./utils/theme.js" +import type { AppProps, TUIMessage, PendingAsk, SayType, AskType, View } from "./types.js" + +/** + * Interface for the extension host that the TUI interacts with + */ +interface ExtensionHostInterface extends EventEmitter { + activate(): Promise + runTask(prompt: string): Promise + sendToExtension(message: unknown): void + dispose(): Promise +} + +export interface TUIAppProps extends AppProps { + /** Extension host factory - allows dependency injection for testing */ + createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface +} + +interface ExtensionHostOptions { + mode: string + reasoningEffort?: string + apiProvider: string + apiKey: string + model: string + workspacePath: string + extensionPath: string + verbose: boolean + quiet: boolean + nonInteractive: boolean + disableOutput: boolean +} + +/** + * Determine the current view state based on messages and pending asks + */ +function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View { + // If there's a pending ask requiring text input, show input + if (pendingAsk?.type === "followup") { + return "UserInput" + } + + // If there's any pending ask (approval), don't show thinking + if (pendingAsk) { + return "UserInput" + } + + // Initial state or empty - awaiting user input + if (messages.length === 0) { + return "UserInput" + } + + const lastMessage = messages.at(-1) + if (!lastMessage) { + return "UserInput" + } + + // User just sent a message, waiting for response + if (lastMessage.role === "user") { + return "AgentResponse" + } + + // Assistant replied + if (lastMessage.role === "assistant") { + if (lastMessage.hasPendingToolCalls) { + return "ToolUse" + } + + // If loading, still waiting for more + if (isLoading) { + return "AgentResponse" + } + + return "UserInput" + } + + // Tool result received, waiting for next assistant response + if (lastMessage.role === "tool") { + return "AgentResponse" + } + + return "Default" +} + +/** + * Full-width horizontal line component - responsive to terminal resize + */ +function HorizontalLine() { + const { columns } = useTerminalSize() + return {"─".repeat(columns)} +} + +/** + * Main TUI Application Component + */ +export function App({ + initialPrompt, + workspacePath, + extensionPath, + apiProvider, + apiKey, + model, + mode, + nonInteractive, + verbose, + debug, + exitOnComplete, + reasoningEffort, + createExtensionHost, +}: TUIAppProps) { + const { exit } = useApp() + + // Zustand store + const { + messages, + pendingAsk, + isLoading, + isComplete, + hasStartedTask, + error, + addMessage, + setPendingAsk, + setLoading, + setComplete, + setHasStartedTask, + setError, + } = useCLIStore() + + const hostRef = useRef(null) + + // Track seen message timestamps to filter duplicates and the prompt echo + const seenMessageIds = useRef>(new Set()) + const firstTextMessageSkipped = useRef(false) + + // Track Ctrl+C presses for "press again to exit" behavior + const [showExitHint, setShowExitHint] = useState(false) + const exitHintTimeout = useRef(null) + const pendingExit = useRef(false) + + // Track whether user wants to type custom response for followup questions + const [showCustomInput, setShowCustomInput] = useState(false) + // Ref to track transition state (handles async state update timing) + const isTransitioningToCustomInput = useRef(false) + + // Determine current view + const view = getView(messages, pendingAsk, isLoading) + + // Display all messages including partial (streaming) ones + // The store handles deduplication by ID, so partial messages get updated in place + const displayMessages = useMemo(() => { + return messages + }, [messages]) + + // Cleanup function + const cleanup = useCallback(async () => { + if (hostRef.current) { + await hostRef.current.dispose() + hostRef.current = null + } + }, []) + + // Handle Ctrl+C - require double press to exit + // Using useInput to capture in raw mode (Ink intercepts SIGINT) + useInput((input, key) => { + if (key.ctrl && input === "c") { + if (pendingExit.current) { + // Second press - exit immediately + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + cleanup().finally(() => { + exit() + process.exit(0) + }) + } else { + // First press - show hint and wait for second press + pendingExit.current = true + setShowExitHint(true) + + // Clear the hint and reset after 2 seconds + exitHintTimeout.current = setTimeout(() => { + pendingExit.current = false + setShowExitHint(false) + exitHintTimeout.current = null + }, 2000) + } + } + }) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + } + }, []) + + // Map extension say messages to TUI messages + const handleSayMessage = useCallback( + (ts: number, say: SayType, text: string, partial: boolean) => { + const messageId = ts.toString() + + // Filter out internal messages we don't want to display + // checkpoint_saved contains internal commit hashes + // api_req_started is verbose technical info + if (say === "checkpoint_saved") { + return + } + if (say === "api_req_started" && !verbose) { + return + } + + // Skip user_feedback - we already display user messages via addMessage() in handleSubmit + // The extension echoes user input as user_feedback which would cause duplicates + if (say === "user_feedback") { + seenMessageIds.current.add(messageId) + return + } + + // Skip the first "text" message - the extension echoes the user's prompt + // We already display the user's message, so skip this echo + if (say === "text" && !firstTextMessageSkipped.current) { + firstTextMessageSkipped.current = true + seenMessageIds.current.add(messageId) + return + } + + // Skip if we've already processed this message ID (except for streaming updates) + if (seenMessageIds.current.has(messageId) && !partial) { + return + } + + // Map say type to role + let role: TUIMessage["role"] = "assistant" + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + + if (say === "command_output") { + // command_output is plain text output from a bash command + role = "tool" + toolName = "execute_command" + toolDisplayName = "bash" + toolDisplayOutput = text + } else if (say === "tool") { + role = "tool" + // Try to parse tool info + try { + const toolInfo = JSON.parse(text) + toolName = toolInfo.tool + toolDisplayName = toolInfo.tool + toolDisplayOutput = formatToolOutput(toolInfo) + } catch { + toolDisplayOutput = text + } + } else if (say === "reasoning" || say === "thinking") { + role = "thinking" + } + + // Track this message ID + seenMessageIds.current.add(messageId) + + // For streaming updates, the store's addMessage handles updating existing messages by ID + addMessage({ + id: messageId, + role, + content: text || "", + toolName, + toolDisplayName, + toolDisplayOutput, + partial, + originalType: say, + }) + }, + [addMessage, verbose], + ) + + // Handle extension ask messages + const handleAskMessage = useCallback( + (ts: number, ask: AskType, text: string, partial: boolean) => { + const messageId = ts.toString() + + // For partial messages, just return + if (partial) { + return + } + + // Skip if we've already processed this ask (e.g., already approved/rejected) + if (seenMessageIds.current.has(messageId)) { + return + } + + // command_output asks are for streaming command output, not for user approval + // They should NOT trigger a Y/N prompt + if (ask === "command_output") { + seenMessageIds.current.add(messageId) + return + } + + // completion_result is handled via the "taskComplete" event, not as a pending ask + // It should show the text input for follow-up, not Y/N prompt + if (ask === "completion_result") { + // Mark task as complete - user can type follow-up + seenMessageIds.current.add(messageId) + setComplete(true) + setLoading(false) + return + } + + // In non-interactive mode, auto-approval is handled by extension settings + if (nonInteractive && ask !== "followup") { + // Show the action being taken + seenMessageIds.current.add(messageId) + + // For tool asks, parse and format nicely + if (ask === "tool") { + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let formattedContent = text || "" + + try { + const toolInfo = JSON.parse(text) as Record + toolName = toolInfo.tool as string + toolDisplayName = toolInfo.tool as string + toolDisplayOutput = formatToolOutput(toolInfo) + formattedContent = formatToolAskMessage(toolInfo) + } catch { + // Use raw text if not valid JSON + } + + addMessage({ + id: messageId, + role: "tool", + content: formattedContent, + toolName, + toolDisplayName, + toolDisplayOutput, + originalType: ask, + }) + } else { + addMessage({ + id: messageId, + role: "assistant", + content: text || "", + originalType: ask, + }) + } + return + } + + // Parse suggestions for followup questions and format tool asks + let suggestions: Array<{ answer: string; mode?: string | null }> | undefined + let questionText = text + + if (ask === "followup") { + try { + const data = JSON.parse(text) + questionText = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : undefined + } catch { + // Use raw text + } + } else if (ask === "tool") { + // Parse tool JSON and format nicely + try { + const toolInfo = JSON.parse(text) as Record + questionText = formatToolAskMessage(toolInfo) + } catch { + // Use raw text if not valid JSON + } + } + + // Mark as seen BEFORE setting pendingAsk to prevent re-processing + seenMessageIds.current.add(messageId) + + // Set pending ask to show approval prompt + setPendingAsk({ + id: messageId, + type: ask, + content: questionText, + suggestions, + }) + }, + [addMessage, setPendingAsk, setComplete, setLoading, nonInteractive], + ) + + // Handle extension messages + const handleExtensionMessage = useCallback( + (message: unknown) => { + const msg = message as Record + + if (msg.type === "state") { + const state = msg.state as Record + if (!state) return + + const clineMessages = state.clineMessages as Array> | undefined + if (clineMessages) { + for (const clineMsg of clineMessages) { + const ts = clineMsg.ts as number + const type = clineMsg.type as string + const say = clineMsg.say as SayType | undefined + const ask = clineMsg.ask as AskType | undefined + const text = (clineMsg.text as string) || "" + const partial = (clineMsg.partial as boolean) || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } + } + } else if (msg.type === "messageUpdated") { + const clineMessage = msg.clineMessage as Record + if (!clineMessage) return + + const ts = clineMessage.ts as number + const type = clineMessage.type as string + const say = clineMessage.say as SayType | undefined + const ask = clineMessage.ask as AskType | undefined + const text = (clineMessage.text as string) || "" + const partial = (clineMessage.partial as boolean) || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } + }, + [handleSayMessage, handleAskMessage], + ) + + // Initialize extension host + useEffect(() => { + const init = async () => { + try { + const host = createExtensionHost({ + mode, + reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort, + apiProvider, + apiKey, + model, + workspacePath, + extensionPath, + verbose: debug, + quiet: !verbose && !debug, + nonInteractive, + disableOutput: true, // TUI mode - Ink handles all rendering + }) + + hostRef.current = host + + // Listen for extension messages + host.on("extensionWebviewMessage", handleExtensionMessage) + + // Listen for task completion + host.on("taskComplete", async () => { + setComplete(true) + setLoading(false) + if (exitOnComplete) { + await cleanup() + exit() + setTimeout(() => process.exit(0), 100) + } + }) + + // Listen for errors + host.on("taskError", (err: string) => { + setError(err) + setLoading(false) + }) + + // Activate the extension + await host.activate() + setLoading(false) + + // Only run task automatically if we have an initial prompt + if (initialPrompt) { + setHasStartedTask(true) + setLoading(true) + // Add user message for the initial prompt + addMessage({ + id: randomUUID(), + role: "user", + content: initialPrompt, + }) + await host.runTask(initialPrompt) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } + + init() + + return () => { + cleanup() + } + }, []) // Run once on mount + + // Handle user input submission + const handleSubmit = useCallback( + async (text: string) => { + if (!hostRef.current || !text.trim()) return + + const trimmedText = text.trim() + + // Guard: don't submit the special "__CUSTOM__" value from Select + if (trimmedText === "__CUSTOM__") { + return + } + + if (pendingAsk) { + // Add user message to chat history + addMessage({ + id: randomUUID(), + role: "user", + content: trimmedText, + }) + + // Send as response to ask + hostRef.current.sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + setPendingAsk(null) + setShowCustomInput(false) + isTransitioningToCustomInput.current = false + setLoading(true) // Show "Thinking" while waiting for response + } else if (!hasStartedTask) { + // First message - start a new task + setHasStartedTask(true) + setLoading(true) + + // Add user message + addMessage({ + id: randomUUID(), + role: "user", + content: trimmedText, + }) + + try { + await hostRef.current.runTask(trimmedText) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } else { + // Send as follow-up message (resume task if it was complete) + if (isComplete) { + setComplete(false) + } + setLoading(true) + + addMessage({ + id: randomUUID(), + role: "user", + content: trimmedText, + }) + + hostRef.current.sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + } + }, + [ + pendingAsk, + hasStartedTask, + isComplete, + addMessage, + setPendingAsk, + setHasStartedTask, + setLoading, + setComplete, + setError, + ], + ) + + // Handle approval (Y key) + const handleApprove = useCallback(() => { + if (!hostRef.current) return + + hostRef.current.sendToExtension({ + type: "askResponse", + askResponse: "yesButtonClicked", + }) + setPendingAsk(null) + setLoading(true) // Show "Thinking" while waiting for response + }, [setPendingAsk, setLoading]) + + // Handle rejection (N key) + const handleReject = useCallback(() => { + if (!hostRef.current) return + + hostRef.current.sendToExtension({ + type: "askResponse", + askResponse: "noButtonClicked", + }) + setPendingAsk(null) + setLoading(true) // Show "Thinking" while waiting for response + }, [setPendingAsk, setLoading]) + + // Handle Y/N input for approval prompts + useInput((input) => { + if (pendingAsk && pendingAsk.type !== "followup") { + const lower = input.toLowerCase() + if (lower === "y") { + handleApprove() + } else if (lower === "n") { + handleReject() + } + } + }) + + // Error display + if (error) { + return ( + + + Error: {error} + + + Press Ctrl+C to exit + + + ) + } + + // Status bar message - shows exit hint or default text + const statusBarMessage = showExitHint ? ( + Press Ctrl+C again to exit + ) : ( + ↑↓ history • ? for shortcuts + ) + + return ( + + {/* Header with ASCII art */} +
+ + {/* Message history - render all completed messages */} + {displayMessages.map((message) => ( + + ))} + + {/* Input area - with borders like Claude Code */} + + {view === "UserInput" ? ( + pendingAsk?.type === "followup" ? ( + + {pendingAsk.content} + {pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? ( + + +