Add a TUI

This commit is contained in:
cte 2026-01-06 00:49:28 -08:00
parent a81438de3e
commit 53ad2e1d6a
23 changed files with 3075 additions and 64 deletions

View file

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

View file

@ -1161,4 +1161,113 @@ describe("ExtensionHost", () => {
vi.useRealTimers()
})
})
describe("handleStateMessage - mode change detection", () => {
let host: ExtensionHost
let sendToExtensionSpy: ReturnType<typeof vi.spyOn>
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()
})
})
})

View file

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

View file

@ -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<string, unknown> = {}
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")
})
})

View file

@ -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<string, unknown>)
}
/**
* Get API key from environment variable based on provider
*/
private getApiKeyFromEnv(provider: string): string | undefined {
const envVarMap: Record<string, string> = {
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<void>((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<void>((resolve) => setTimeout(resolve, 100))
}
if (this.options.apiKey) {
this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() })
await new Promise<void>((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<void>((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<string, unknown> | 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<Record<string, unknown>> | 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)

View file

@ -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("<prompt>", "The prompt/task to execute")
.argument("[prompt]", "The prompt/task to execute (optional in TUI mode)")
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
.option("-e, --extension <path>", "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 <prompt> [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)
}
}
},
)

929
apps/cli/src/ui/App.tsx Normal file
View file

@ -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<void>
runTask(prompt: string): Promise<void>
sendToExtension(message: unknown): void
dispose(): Promise<void>
}
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 <Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
}
/**
* 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<ExtensionHostInterface | null>(null)
// Track seen message timestamps to filter duplicates and the prompt echo
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track Ctrl+C presses for "press again to exit" behavior
const [showExitHint, setShowExitHint] = useState(false)
const exitHintTimeout = useRef<NodeJS.Timeout | null>(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<string, unknown>
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<string, unknown>
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<string, unknown>
if (msg.type === "state") {
const state = msg.state as Record<string, unknown>
if (!state) return
const clineMessages = state.clineMessages as Array<Record<string, unknown>> | 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<string, unknown>
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 (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error: {error}
</Text>
<Text color="gray" dimColor>
Press Ctrl+C to exit
</Text>
</Box>
)
}
// Status bar message - shows exit hint or default text
const statusBarMessage = showExitHint ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : (
<Text color={theme.dimText}> history ? for shortcuts</Text>
)
return (
<Box flexDirection="column">
{/* Header with ASCII art */}
<Header model={model} mode={mode} cwd={workspacePath} reasoningEffort={reasoningEffort} />
{/* Message history - render all completed messages */}
{displayMessages.map((message) => (
<ChatHistoryItem key={message.id} message={message} />
))}
{/* Input area - with borders like Claude Code */}
<Box flexDirection="column" marginTop={1}>
{view === "UserInput" ? (
pendingAsk?.type === "followup" ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine />
<Select
options={[
...pendingAsk.suggestions.map((s) => ({
label: s.answer,
value: s.answer,
})),
{ label: "Type something...", value: "__CUSTOM__" },
]}
onChange={(value) => {
// Guard: Ignore empty, undefined, or invalid values
if (!value || typeof value !== "string") return
// Guard: Ignore if we're already transitioning or showing custom input
if (showCustomInput || isTransitioningToCustomInput.current) return
if (value === "__CUSTOM__") {
// Don't send any response - just switch to text input mode
// Use ref to prevent race conditions during state update
isTransitioningToCustomInput.current = true
setShowCustomInput(true)
} else if (value.trim()) {
// Only submit valid non-empty values
handleSubmit(value)
}
}}
/>
<HorizontalLine />
<Text color={theme.dimText}> navigate Enter select</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine />
<Box>
<Text color={theme.promptColor}>&gt; </Text>
<TextInput
placeholder="Type your response..."
onSubmit={(text) => {
// Only submit if there's actual text
if (text && text.trim()) {
handleSubmit(text)
setShowCustomInput(false)
isTransitioningToCustomInput.current = false
}
}}
/>
</Box>
<HorizontalLine />
{statusBarMessage}
</Box>
)}
</Box>
) : pendingAsk ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
<Text color={theme.dimText}>
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
<Text color={theme.errorColor}>N</Text> to reject
</Text>
</Box>
) : isComplete ? (
<Box flexDirection="column">
<HorizontalLine />
<Box>
<Text color={theme.promptColor}>&gt; </Text>
<HistoryTextInput
placeholder="Type to continue..."
onSubmit={handleSubmit}
isActive={view === "UserInput"}
/>
</Box>
<HorizontalLine />
{statusBarMessage}
</Box>
) : (
<Box flexDirection="column">
<HorizontalLine />
<Box>
<Text color={theme.promptColor}> </Text>
<HistoryTextInput
placeholder=""
onSubmit={handleSubmit}
isActive={view === "UserInput"}
/>
</Box>
<HorizontalLine />
{statusBarMessage}
</Box>
)
) : view === "ToolUse" ? (
<Box paddingX={1}>
<LoadingText>Using tool</LoadingText>
</Box>
) : (
<Box paddingX={1}>
<LoadingText>Thinking</LoadingText>
</Box>
)}
</Box>
</Box>
)
}
/**
* Format tool output for display (used in the message body, header shows tool name separately)
*/
function formatToolOutput(toolInfo: Record<string, unknown>): string {
const toolName = (toolInfo.tool as string) || "unknown"
// Handle specific tool types with friendly formatting
switch (toolName) {
case "switchMode": {
const mode = (toolInfo.mode as string) || "unknown"
const reason = toolInfo.reason as string
return `${mode} mode${reason ? `\n ${reason}` : ""}`
}
case "switch_mode": {
const mode = (toolInfo.mode_slug as string) || (toolInfo.mode as string) || "unknown"
const reason = toolInfo.reason as string
return `${mode} mode${reason ? `\n ${reason}` : ""}`
}
case "execute_command": {
const command = toolInfo.command as string
return `$ ${command || "(no command)"}`
}
case "read_file": {
const files = toolInfo.files as Array<{ path: string }> | undefined
const path = toolInfo.path as string
if (files && files.length > 0) {
return files.map((f) => `📄 ${f.path}`).join("\n")
}
return `📄 ${path || "(no path)"}`
}
case "write_to_file": {
const writePath = toolInfo.path as string
return `📝 ${writePath || "(no path)"}`
}
case "apply_diff": {
const diffPath = toolInfo.path as string
return `✏️ ${diffPath || "(no path)"}`
}
case "search_files": {
const searchPath = toolInfo.path as string
const regex = toolInfo.regex as string
return `🔍 "${regex}" in ${searchPath || "."}`
}
case "list_files": {
const listPath = toolInfo.path as string
const recursive = toolInfo.recursive as boolean
return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}`
}
case "browser_action": {
const action = toolInfo.action as string
const url = toolInfo.url as string
return `🌐 ${action || "action"}${url ? `: ${url}` : ""}`
}
case "attempt_completion": {
const result = toolInfo.result as string
if (result) {
const truncated = result.length > 100 ? result.substring(0, 100) + "..." : result
return `${truncated}`
}
return "✅ Task completed"
}
case "ask_followup_question": {
const question = toolInfo.question as string
return `${question || "(no question)"}`
}
case "new_task": {
const taskMode = toolInfo.mode as string
return `📋 Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}`
}
default: {
// Generic formatting - show params without the tool name (it's in the header)
const params = Object.entries(toolInfo)
.filter(([key]) => key !== "tool")
.map(([key, value]) => {
const displayValue = typeof value === "string" ? value : JSON.stringify(value)
const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue
return `${key}: ${truncated}`
})
.join("\n")
return params || "(no parameters)"
}
}
}
/**
* Format tool ask message for user approval prompt
*/
function formatToolAskMessage(toolInfo: Record<string, unknown>): string {
const toolName = (toolInfo.tool as string) || "unknown"
// Handle specific tool types with nice formatting for approval prompts
switch (toolName) {
case "switchMode":
case "switch_mode": {
const mode = (toolInfo.mode as string) || (toolInfo.mode_slug as string) || "unknown"
const reason = toolInfo.reason as string
return `Switch to ${mode} mode?${reason ? `\nReason: ${reason}` : ""}`
}
case "execute_command": {
const command = toolInfo.command as string
return `Run command?\n$ ${command || "(no command)"}`
}
case "read_file": {
const files = toolInfo.files as Array<{ path: string }> | undefined
const path = toolInfo.path as string
if (files && files.length > 0) {
return `Read ${files.length} file(s)?\n${files.map((f) => ` ${f.path}`).join("\n")}`
}
return `Read file: ${path || "(no path)"}`
}
case "write_to_file": {
const writePath = toolInfo.path as string
return `Write to file: ${writePath || "(no path)"}`
}
case "apply_diff": {
const diffPath = toolInfo.path as string
return `Apply changes to: ${diffPath || "(no path)"}`
}
case "browser_action": {
const action = toolInfo.action as string
const url = toolInfo.url as string
return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}`
}
default: {
// Generic formatting for other tools
const params = Object.entries(toolInfo)
.filter(([key]) => key !== "tool")
.map(([key, value]) => {
const displayValue = typeof value === "string" ? value : JSON.stringify(value)
const truncated = displayValue.length > 80 ? displayValue.substring(0, 80) + "..." : displayValue
return ` ${key}: ${truncated}`
})
.join("\n")
return `${toolName}${params ? `\n${params}` : ""}`
}
}
}

View file

@ -0,0 +1,79 @@
import { memo } from "react"
import { Box, Newline, Text } from "ink"
import * as theme from "../utils/theme.js"
import type { TUIMessage } from "../types.js"
interface ChatHistoryItemProps {
message: TUIMessage
}
function ChatHistoryItem({ message }: ChatHistoryItemProps) {
const content = message.content || "<no content>"
switch (message.role) {
case "user":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.userHeader}>
user
</Text>
<Text color={theme.userText}>
{content}
<Newline />
</Text>
</Box>
)
case "assistant":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.rooHeader}>
roo
</Text>
<Text color={theme.rooText}>
{content}
<Newline />
</Text>
</Box>
)
case "thinking":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.thinkingHeader} dimColor>
thinking
</Text>
<Text color={theme.thinkingText} dimColor>
{content}
<Newline />
</Text>
</Box>
)
case "tool":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.toolHeader}>
{`tool - ${message.toolDisplayName || message.toolName || "unknown"}`}
</Text>
<Text color={theme.toolText}>
{message.toolDisplayOutput || content}
<Newline />
</Text>
</Box>
)
case "system":
// System messages are typically rendered as Header, not here.
// But if they appear, show them subtly.
return (
<Box flexDirection="column" paddingX={1}>
<Text color="gray" dimColor>
{content}
<Newline />
</Text>
</Box>
)
default:
return null
}
}
export default memo(ChatHistoryItem)

View file

@ -0,0 +1,58 @@
import { memo } from "react"
import { Text, Box } from "ink"
import { useTerminalSize } from "../hooks/useTerminalSize.js"
import * as theme from "../utils/theme.js"
interface HeaderProps {
cwd: string
model: string
mode: string
reasoningEffort?: string
version?: string
}
const ASCII_ROO = ` _,' ___
<__\\__/ \\
\\_ / _\\
\\,\\ / \\\\
// \\\\
,/' \`\\_,`
function HorizontalLine() {
const { columns } = useTerminalSize()
return <Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
}
function Header({ model, cwd, mode, reasoningEffort, version = "0.1.0" }: HeaderProps) {
const { columns } = useTerminalSize()
const homeDir = process.env.HOME || process.env.USERPROFILE || ""
const displayCwd = cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd
const title = `Roo Code CLI v${version}`
const titlePart = `── ${title} `
const remainingDashes = Math.max(0, columns - titlePart.length)
return (
<Box flexDirection="column" width={columns}>
<Text color={theme.borderColor}>
<Text color={theme.titleColor}>{title}</Text> {"─".repeat(remainingDashes)}
</Text>
<Box width={columns}>
<Box flexDirection="row">
<Box marginY={1}>
<Text color={theme.asciiColor}>{ASCII_ROO}</Text>
</Box>
<Box flexDirection="column" marginLeft={1} marginTop={1}>
<Text color={theme.dimText}>Workspace: {displayCwd}</Text>
<Text color={theme.dimText}>Mode: {mode}</Text>
<Text color={theme.dimText}>Model: {model}</Text>
<Text color={theme.dimText}>Reasoning: {reasoningEffort}</Text>
</Box>
</Box>
</Box>
<HorizontalLine />
</Box>
)
}
export default memo(Header)

View file

@ -0,0 +1,125 @@
/**
* HistoryTextInput Component
*
* A TextInput wrapper that provides history navigation with up/down arrow keys.
* Uses a key-based remount strategy to work with @inkjs/ui's uncontrolled TextInput.
*/
import { Box } from "ink"
import { TextInput } from "@inkjs/ui"
import { useCallback, useState, useEffect, useRef } from "react"
import { useInputHistory } from "../hooks/useInputHistory.js"
export interface HistoryTextInputProps {
/** Placeholder text when input is empty */
placeholder?: string
/** Called when user submits input */
onSubmit: (value: string) => void
/** Whether history navigation is active */
isActive?: boolean
/** Whether to add submitted values to history */
addToHistory?: boolean
}
/**
* TextInput with history navigation support.
*
* - Up arrow: Navigate to older history entries (saves current input as draft)
* - Down arrow: Navigate to newer entries or return to draft
* - History persists to ~/.roo/cli-history.json
*/
export function HistoryTextInput({
placeholder = "Type your message...",
onSubmit,
isActive = true,
addToHistory = true,
}: HistoryTextInputProps) {
// Track the current input value via onChange
const currentInputRef = useRef("")
const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft } = useInputHistory({
isActive,
getCurrentInput: () => currentInputRef.current,
})
// Track previous browsing state to detect when we return from browsing
const [wasBrowsing, setWasBrowsing] = useState(false)
// Use a key to force remount when we need to change the defaultValue
const [inputKey, setInputKey] = useState(0)
// Track what value we're currently showing
const [displayValue, setDisplayValue] = useState("")
// Handle changes when entering or exiting history browsing
useEffect(() => {
if (isBrowsing && !wasBrowsing) {
// Just started browsing - show history value
if (historyValue !== null) {
setDisplayValue(historyValue)
setInputKey((k) => k + 1)
}
} else if (!isBrowsing && wasBrowsing) {
// Just stopped browsing - restore draft
setDisplayValue(draft)
setInputKey((k) => k + 1)
// Reset the current input ref to draft
currentInputRef.current = draft
} else if (isBrowsing && historyValue !== null && historyValue !== displayValue) {
// Navigating within history
setDisplayValue(historyValue)
setInputKey((k) => k + 1)
}
setWasBrowsing(isBrowsing)
}, [isBrowsing, wasBrowsing, historyValue, draft, displayValue])
// Handle input changes
const handleChange = useCallback(
(value: string) => {
currentInputRef.current = value
// Also update draft if we're not browsing
if (!isBrowsing) {
setDraft(value)
}
},
[isBrowsing, setDraft],
)
// Handle submit
const handleSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
// Add to history if enabled
if (addToHistory) {
await addEntry(trimmed)
}
// Reset browsing state and clear draft
resetBrowsing("")
currentInputRef.current = ""
setDisplayValue("")
setInputKey((k) => k + 1)
// Call parent submit handler
onSubmit(trimmed)
},
[addToHistory, addEntry, resetBrowsing, onSubmit],
)
return (
<Box>
<TextInput
key={`history-input-${inputKey}-${history.length}`}
defaultValue={displayValue}
placeholder={placeholder}
onChange={handleChange}
onSubmit={handleSubmit}
/>
</Box>
)
}
export default HistoryTextInput

View file

@ -0,0 +1,41 @@
import { Spinner } from "@inkjs/ui"
import { memo, useMemo } from "react"
const THINKING_PHRASES = [
"Thinking",
"Pondering",
"Contemplating",
"Reticulating",
"Marinating",
"Actualizing",
"Crunching",
"Untangling",
"Summoning",
"Conjuring",
"Materializing",
"Synthesizing",
"Assembling",
"Percolating",
"Brewing",
"Manifesting",
"Cogitating",
]
interface LoadingTextProps {
children?: React.ReactNode
}
function LoadingText({ children }: LoadingTextProps) {
const randomPhrase = useMemo(() => {
const randomIndex = Math.floor(Math.random() * THINKING_PHRASES.length)
return THINKING_PHRASES[randomIndex]
}, [])
const childrenStr = children ? String(children) : ""
const useRandomPhrase = !children || childrenStr === "Thinking"
const label = useRandomPhrase ? `${randomPhrase}...` : `${childrenStr}...`
return <Spinner label={label} />
}
export default memo(LoadingText)

View file

@ -0,0 +1,162 @@
/**
* TextInput Component - User input field for the TUI
* Uses @inkjs/ui TextInput for ink v6 compatibility
*/
import { Box, Text, useInput } from "ink"
import { TextInput as InkTextInput } from "@inkjs/ui"
import { useState, useCallback } from "react"
export interface TextInputProps {
/** Current input value */
value: string
/** Called when input changes */
onChange: (value: string) => void
/** Called when user submits input */
onSubmit: (value: string) => void
/** Placeholder text when empty */
placeholder?: string
/** Whether input is disabled */
disabled?: boolean
}
/**
* Text input component with submit handling
*/
export function TextInput({
value,
onChange,
onSubmit,
placeholder = "Type your message...",
disabled = false,
}: TextInputProps) {
const handleSubmit = useCallback(
(inputValue: string) => {
const trimmed = inputValue.trim()
if (trimmed && !disabled) {
onSubmit(trimmed)
onChange("") // Clear input after submit
}
},
[onSubmit, onChange, disabled],
)
if (disabled) {
return (
<Box borderStyle="bold" borderColor="gray" paddingX={1}>
<Text color="gray" dimColor>
{placeholder}
</Text>
</Box>
)
}
return (
<Box borderStyle="bold" borderColor="blue" paddingX={1}>
<InkTextInput defaultValue={value} placeholder={placeholder} onSubmit={handleSubmit} />
</Box>
)
}
export interface ApprovalPromptProps {
/** The question or action to approve */
message: string
/** Suggested answers (for followup questions) */
suggestions?: Array<{ answer: string; mode?: string | null }>
/** Called when user approves */
onApprove: () => void
/** Called when user rejects */
onReject: () => void
/** Called when user provides text response */
onTextResponse?: (text: string) => void
}
/**
* Approval prompt for yes/no decisions
*/
export function ApprovalPrompt({ message, suggestions, onApprove, onReject, onTextResponse }: ApprovalPromptProps) {
const [inputValue, _setInputValue] = useState("")
// Handle keyboard input for Y/N
useInput((input) => {
const lower = input.toLowerCase()
if (lower === "y") {
onApprove()
} else if (lower === "n") {
onReject()
} else if (suggestions && !isNaN(parseInt(input, 10))) {
const index = parseInt(input, 10) - 1
const suggestion = suggestions[index]
if (index >= 0 && index < suggestions.length && suggestion) {
onTextResponse?.(suggestion.answer)
}
}
})
return (
<Box flexDirection="column" borderStyle="bold" borderColor="yellow" paddingX={1}>
<Text color="yellow" bold>
{message}
</Text>
{suggestions && suggestions.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Suggestions:</Text>
{suggestions.map((suggestion, index) => (
<Box key={index}>
<Text color="cyan">
{index + 1}. {suggestion.answer}
{suggestion.mode && (
<Text color="gray" dimColor>
{" "}
(mode: {suggestion.mode})
</Text>
)}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
{onTextResponse ? (
<Box flexDirection="column">
<Text color="gray">
Type a number (1-{suggestions?.length || 0}), type your answer, or press{" "}
<Text color="green" bold>
Y
</Text>
/
<Text color="red" bold>
N
</Text>
</Text>
<Box marginTop={1} borderStyle="bold" borderColor="blue" paddingX={1}>
<InkTextInput
defaultValue={inputValue}
placeholder="Your response..."
onSubmit={(val) => {
if (val.trim()) {
onTextResponse(val.trim())
}
}}
/>
</Box>
</Box>
) : (
<Text color="gray">
Press{" "}
<Text color="green" bold>
Y
</Text>{" "}
to approve,{" "}
<Text color="red" bold>
N
</Text>{" "}
to reject
</Text>
)}
</Box>
</Box>
)
}

View file

@ -0,0 +1,195 @@
/**
* useInputHistory Hook
*
* Provides input history navigation for CLI text inputs.
* Uses up/down arrow keys to navigate through previously entered prompts.
* History is persisted to ~/.roo/cli-history.json
*/
import { useState, useEffect, useCallback, useRef } from "react"
import { useInput } from "ink"
import { loadHistory, addToHistory } from "../../utils/historyStorage.js"
export interface UseInputHistoryOptions {
/**
* Whether the hook should respond to arrow key input.
* Set to false when input is not active/focused.
* @default true
*/
isActive?: boolean
/**
* Callback to get the current input value when starting to browse history.
* This allows saving the user's draft before navigating.
*/
getCurrentInput?: () => string
}
export interface UseInputHistoryReturn {
/**
* Add a new entry to history (call on submit)
*/
addEntry: (entry: string) => Promise<void>
/**
* Current history value being browsed, or null if not browsing.
* Use this value to display in the input when browsing history.
*/
historyValue: string | null
/**
* Whether currently browsing through history
*/
isBrowsing: boolean
/**
* Reset browsing state and optionally save current input as draft.
* Call this when user starts typing.
*/
resetBrowsing: (currentInput?: string) => void
/**
* All history entries (oldest first)
*/
history: string[]
/**
* The saved draft (what user was typing before navigating history)
*/
draft: string
/**
* Set the current draft value (call from onChange)
*/
setDraft: (value: string) => void
}
/**
* Hook for managing input history with up/down arrow navigation
*
* @example
* ```tsx
* const { addEntry, historyValue, draft, setDraft } = useInputHistory({ isActive: true });
*
* // Track current input via onChange
* <TextInput onChange={setDraft} ... />
*
* // When user submits input:
* const handleSubmit = async (text: string) => {
* await addEntry(text);
* // ... handle submission
* };
*
* // Use historyValue to control input:
* // If historyValue is not null, display it instead of current input
* ```
*/
export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputHistoryReturn {
const { isActive = true, getCurrentInput } = options
// All history entries (oldest first, newest at end)
const [history, setHistory] = useState<string[]>([])
// Current position in history (-1 = not browsing, 0 = oldest, history.length-1 = newest)
const [historyIndex, setHistoryIndex] = useState(-1)
// The user's typed text before they started navigating history
const [draft, setDraft] = useState("")
// Flag to track if history has been loaded
const historyLoaded = useRef(false)
// Load history on mount
useEffect(() => {
if (!historyLoaded.current) {
historyLoaded.current = true
loadHistory()
.then(setHistory)
.catch(() => {
// Ignore load errors - history is not critical
})
}
}, [])
// Handle up/down arrow keys for history navigation
useInput(
(_input, key) => {
if (!isActive) return
if (key.upArrow) {
// Navigate to older entry
if (history.length === 0) return
if (historyIndex === -1) {
// Starting to browse - save current input as draft
if (getCurrentInput) {
setDraft(getCurrentInput())
}
// Go to newest entry
setHistoryIndex(history.length - 1)
} else if (historyIndex > 0) {
// Go to older entry
setHistoryIndex(historyIndex - 1)
}
// At oldest entry - stay there
} else if (key.downArrow) {
// Navigate to newer entry
if (historyIndex === -1) return // Not browsing
if (historyIndex < history.length - 1) {
// Go to newer entry
setHistoryIndex(historyIndex + 1)
} else {
// At newest entry - return to draft
setHistoryIndex(-1)
}
}
},
{ isActive },
)
// Add new entry to history
const addEntry = useCallback(async (entry: string) => {
const trimmed = entry.trim()
if (!trimmed) return
try {
const updated = await addToHistory(trimmed)
setHistory(updated)
} catch {
// Ignore save errors - history is not critical
}
// Reset navigation state
setHistoryIndex(-1)
setDraft("")
}, [])
// Reset browsing state
const resetBrowsing = useCallback((currentInput?: string) => {
setHistoryIndex(-1)
if (currentInput !== undefined) {
setDraft(currentInput)
}
}, [])
// Calculate the current history value to display
// When browsing, show history entry; when returning from browsing, show draft
let historyValue: string | null = null
if (historyIndex >= 0 && historyIndex < history.length) {
historyValue = history[historyIndex] ?? null
}
const isBrowsing = historyIndex !== -1
return {
addEntry,
historyValue,
isBrowsing,
resetBrowsing,
history,
draft,
setDraft,
}
}

View file

@ -0,0 +1,58 @@
/**
* useTerminalSize - Hook that tracks terminal dimensions and re-renders on resize
* Includes debouncing to prevent rendering issues during rapid resizing
*/
import { useState, useEffect, useRef } from "react"
interface TerminalSize {
columns: number
rows: number
}
/**
* Returns the current terminal size and re-renders when it changes
* Debounces resize events to prevent rendering artifacts
*/
export function useTerminalSize(): TerminalSize {
const [size, setSize] = useState<TerminalSize>(() => ({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
}))
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const handleResize = () => {
// Clear any pending debounce
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
// Debounce resize events by 50ms
debounceTimer.current = setTimeout(() => {
// Clear the terminal before updating size to prevent artifacts
process.stdout.write("\x1b[2J\x1b[H")
setSize({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
})
debounceTimer.current = null
}, 50)
}
// Listen for resize events
process.stdout.on("resize", handleResize)
// Cleanup
return () => {
process.stdout.off("resize", handleResize)
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
return size
}

21
apps/cli/src/ui/index.ts Normal file
View file

@ -0,0 +1,21 @@
// Main App
export { type TUIAppProps, App } from "./App.js"
// Components
export { default as Header } from "./components/Header.js"
export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js"
export { default as LoadingText } from "./components/LoadingText.js"
export { TextInput, ApprovalPrompt } from "./components/TextInput.js"
// Hooks
export { useInputHistory } from "./hooks/useInputHistory.js"
export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useInputHistory.js"
// Store
export { useCLIStore } from "./store.js"
// Theme
export * as theme from "./utils/theme.js"
// Types
export type { TUIMessage, PendingAsk, SayType, AskType, AppProps, MessageRole, View } from "./types.js"

84
apps/cli/src/ui/store.ts Normal file
View file

@ -0,0 +1,84 @@
import { create } from "zustand"
import type { TUIMessage, PendingAsk } from "./types.js"
interface CLIState {
messages: TUIMessage[]
pendingAsk: PendingAsk | null
isLoading: boolean
isComplete: boolean
hasStartedTask: boolean
error: string | null
}
interface CLIActions {
addMessage: (msg: TUIMessage) => void
updateMessage: (id: string, content: string, partial?: boolean) => void
setPendingAsk: (ask: PendingAsk | null) => void
setLoading: (loading: boolean) => void
setComplete: (complete: boolean) => void
setHasStartedTask: (started: boolean) => void
setError: (error: string | null) => void
reset: () => void
}
const initialState: CLIState = {
messages: [],
pendingAsk: null,
isLoading: false,
isComplete: false,
hasStartedTask: false,
error: null,
}
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
...initialState,
addMessage: (msg) =>
set((state) => {
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
if (existingIndex !== -1) {
// Update existing message in place.
const updated = [...state.messages]
updated[existingIndex] = msg
return { messages: updated }
}
// Add new message.
return { messages: [...state.messages, msg] }
}),
updateMessage: (id, content, partial) =>
set((state) => {
const index = state.messages.findIndex((m) => m.id === id)
if (index === -1) {
return state
}
const existing = state.messages[index]
if (!existing) {
return state
}
const updated = [...state.messages]
updated[index] = {
...existing,
content,
partial: partial !== undefined ? partial : existing.partial,
}
return { messages: updated }
}),
setPendingAsk: (ask) => set({ pendingAsk: ask }),
setLoading: (loading) => set({ isLoading: loading }),
setComplete: (complete) => set({ isComplete: complete }),
setHasStartedTask: (started) => set({ hasStartedTask: started }),
setError: (error) => set({ error }),
reset: () => set(initialState),
}))

73
apps/cli/src/ui/types.ts Normal file
View file

@ -0,0 +1,73 @@
export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
/**
* Ask types that require user input.
*/
export type AskType =
| "followup"
| "command"
| "command_output"
| "tool"
| "browser_action_launch"
| "use_mcp_server"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "completion_result"
/**
* Say types for display-only messages.
*/
export type SayType =
| "text"
| "reasoning"
| "thinking"
| "command_output"
| "completion_result"
| "error"
| "tool"
| "api_req_started"
| "user_feedback"
| "checkpoint_saved"
/**
* A message displayed in the TUI message list.
*/
export interface TUIMessage {
id: string
role: MessageRole
content: string
toolName?: string
toolDisplayName?: string
toolDisplayOutput?: string
hasPendingToolCalls?: boolean
partial?: boolean
originalType?: SayType | AskType
}
/**
* A pending ask that requires user response.
*/
export interface PendingAsk {
id: string
type: AskType
content: string
suggestions?: Array<{ answer: string; mode?: string | null }>
}
export interface AppProps {
initialPrompt: string
workspacePath: string
extensionPath: string
apiProvider: string
apiKey: string
model: string
mode: string
nonInteractive: boolean
verbose: boolean
debug: boolean
exitOnComplete: boolean
reasoningEffort?: string
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"

View file

@ -0,0 +1,78 @@
/**
* Theme configuration for Roo Code CLI TUI
* Using Catppuccin Mocha color scheme
*/
// Catppuccin Mocha palette
const catppuccin = {
// Accent colors
rosewater: "#f5e0dc",
flamingo: "#f2cdcd",
pink: "#f5c2e7",
mauve: "#cba6f7",
red: "#f38ba8",
maroon: "#eba0ac",
peach: "#fab387",
yellow: "#f9e2af",
green: "#a6e3a1",
teal: "#94e2d5",
sky: "#89dceb",
sapphire: "#74c7ec",
blue: "#89b4fa",
lavender: "#b4befe",
// Text colors
text: "#cdd6f4",
subtext1: "#bac2de",
subtext0: "#a6adc8",
// Overlay colors
overlay2: "#9399b2",
overlay1: "#7f849c",
overlay0: "#6c7086",
// Surface colors
surface2: "#585b70",
surface1: "#45475a",
surface0: "#313244",
// Base colors
base: "#1e1e2e",
mantle: "#181825",
crust: "#11111b",
}
// Title and branding colors
export const titleColor = catppuccin.peach // Peach for title
export const welcomeText = catppuccin.text // Standard text
export const asciiColor = catppuccin.blue // Blue for ASCII art
// Tips section colors
export const tipsHeader = catppuccin.peach // Peach for tips headers
export const tipsText = catppuccin.subtext0 // Subtle text for tips
// Header text colors (for messages)
export const userHeader = catppuccin.lavender // Lavender for user header
export const rooHeader = catppuccin.yellow // Yellow for roo
export const toolHeader = catppuccin.teal // Teal for tool headers
export const thinkingHeader = catppuccin.overlay1 // Subtle gray for thinking header
// Message text colors
export const userText = catppuccin.text // Standard text for user
export const rooText = catppuccin.text // Standard text for roo
export const toolText = catppuccin.subtext0 // Subtle text for tool output
export const thinkingText = catppuccin.overlay2 // Subtle gray for thinking text
// UI element colors
export const borderColor = catppuccin.surface1 // Surface color for borders
export const dimText = catppuccin.overlay1 // Dim text
export const promptColor = catppuccin.overlay2 // Prompt indicator
export const placeholderColor = catppuccin.overlay0 // Placeholder text
// Status colors
export const successColor = catppuccin.green // Green for success
export const errorColor = catppuccin.red // Red for errors
export const warningColor = catppuccin.yellow // Yellow for warnings
// Base text color
export const text = catppuccin.text // Standard text color

View file

@ -0,0 +1,131 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
/** Maximum number of history entries to keep */
export const MAX_HISTORY_ENTRIES = 500
/** History file format version for future migrations */
const HISTORY_VERSION = 1
interface HistoryData {
version: number
entries: string[]
}
/**
* Get the path to the history file
*/
export function getHistoryFilePath(): string {
return path.join(os.homedir(), ".roo", "cli-history.json")
}
/**
* Get the path to the .roo directory
*/
function getRooDir(): string {
return path.join(os.homedir(), ".roo")
}
/**
* Ensure the .roo directory exists
*/
async function ensureRooDir(): Promise<void> {
const rooDir = getRooDir()
try {
await fs.mkdir(rooDir, { recursive: true })
} catch (err) {
// Directory may already exist, that's fine
const error = err as NodeJS.ErrnoException
if (error.code !== "EEXIST") {
throw err
}
}
}
/**
* Load history entries from file
* Returns empty array if file doesn't exist or is invalid
*/
export async function loadHistory(): Promise<string[]> {
const filePath = getHistoryFilePath()
try {
const content = await fs.readFile(filePath, "utf-8")
const data: HistoryData = JSON.parse(content)
// Validate structure
if (!data || typeof data !== "object") {
return []
}
if (!Array.isArray(data.entries)) {
return []
}
// Filter to only valid strings
return data.entries.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
} catch (err) {
const error = err as NodeJS.ErrnoException
// File doesn't exist - that's expected on first run
if (error.code === "ENOENT") {
return []
}
// JSON parse error or other issue - log and return empty
console.error("Warning: Could not load CLI history:", error.message)
return []
}
}
/**
* Save history entries to file
* Creates the .roo directory if needed
* Trims to MAX_HISTORY_ENTRIES
*/
export async function saveHistory(entries: string[]): Promise<void> {
const filePath = getHistoryFilePath()
// Trim to max entries (keep most recent)
const trimmedEntries = entries.slice(-MAX_HISTORY_ENTRIES)
const data: HistoryData = {
version: HISTORY_VERSION,
entries: trimmedEntries,
}
try {
await ensureRooDir()
await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8")
} catch (err) {
const error = err as NodeJS.ErrnoException
// Log but don't throw - history persistence is not critical
console.error("Warning: Could not save CLI history:", error.message)
}
}
/**
* Add a new entry to history and save
* Avoids adding consecutive duplicates or empty entries
* Returns the updated history array
*/
export async function addToHistory(entry: string): Promise<string[]> {
const trimmed = entry.trim()
// Don't add empty entries
if (!trimmed) {
return await loadHistory()
}
const history = await loadHistory()
// Don't add consecutive duplicates
if (history.length > 0 && history[history.length - 1] === trimmed) {
return history
}
const updated = [...history, trimmed]
await saveHistory(updated)
return updated.slice(-MAX_HISTORY_ENTRIES)
}

View file

@ -2,7 +2,9 @@
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]

View file

@ -21,4 +21,9 @@ export default defineConfig({
// Keep @vscode/ripgrep external - we bundle the binary separately
"@vscode/ripgrep",
],
esbuildOptions(options) {
// Enable JSX for React/Ink components
options.jsx = "automatic"
options.jsxImportSource = "react"
},
})

270
pnpm-lock.yaml generated
View file

@ -80,6 +80,9 @@ importers:
apps/cli:
dependencies:
'@inkjs/ui':
specifier: ^2.0.0
version: 2.0.0(ink@6.6.0(@types/react@19.2.7)(react@19.2.3))
'@roo-code/types':
specifier: workspace:^
version: link:../../packages/types
@ -92,6 +95,15 @@ importers:
commander:
specifier: ^12.1.0
version: 12.1.0
ink:
specifier: ^6.6.0
version: 6.6.0(@types/react@19.2.7)(react@19.2.3)
react:
specifier: ^19.1.0
version: 19.2.3
zustand:
specifier: ^5.0.0
version: 5.0.9(@types/react@19.2.7)(react@19.2.3)
devDependencies:
'@roo-code/config-eslint':
specifier: workspace:^
@ -102,6 +114,9 @@ importers:
'@types/node':
specifier: ^24.1.0
version: 24.2.1
'@types/react':
specifier: ^19.1.6
version: 19.2.7
rimraf:
specifier: ^6.0.1
version: 6.0.1
@ -1327,6 +1342,10 @@ packages:
'@adobe/css-tools@4.4.2':
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
'@alcalzone/ansi-tokenize@0.2.3':
resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==}
engines: {node: '>=18'}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -2170,6 +2189,12 @@ packages:
cpu: [x64]
os: [win32]
'@inkjs/ui@2.0.0':
resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==}
engines: {node: '>=18'}
peerDependencies:
ink: '>=5'
'@inquirer/external-editor@1.0.2':
resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==}
engines: {node: '>=18'}
@ -4246,6 +4271,9 @@ packages:
'@types/react@18.3.23':
resolution: {integrity: sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==}
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
@ -4534,6 +4562,10 @@ packages:
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
engines: {node: '>=18'}
ansi-escapes@7.2.0:
resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==}
engines: {node: '>=18'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -4669,6 +4701,10 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
auto-bind@5.0.1:
resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
autoprefixer@10.4.21:
resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==}
engines: {node: ^10 || ^12 || >=14}
@ -4908,6 +4944,10 @@ packages:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
@ -4983,6 +5023,14 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
cli-cursor@4.0.0:
resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@ -4991,10 +5039,18 @@ packages:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-spinners@3.3.0:
resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==}
engines: {node: '>=18.20'}
cli-truncate@4.0.0:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
cli-truncate@5.1.1:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@ -5037,6 +5093,10 @@ packages:
resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==}
engines: {node: '>=16'}
code-excerpt@4.0.0:
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
@ -5134,6 +5194,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
convert-to-spaces@2.0.1:
resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@ -5231,6 +5295,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
cytoscape-cose-bilkent@4.1.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
@ -5488,6 +5555,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
default-browser-id@5.0.0:
resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
engines: {node: '>=18'}
@ -5870,6 +5941,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es-toolkit@1.43.0:
resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==}
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@ -6727,12 +6801,29 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
indent-string@5.0.0:
resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==}
engines: {node: '>=12'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ink@6.6.0:
resolution: {integrity: sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==}
engines: {node: '>=20'}
peerDependencies:
'@types/react': '>=19.0.0'
react: '>=19.0.0'
react-devtools-core: ^6.1.2
peerDependenciesMeta:
'@types/react':
optional: true
react-devtools-core:
optional: true
inline-style-parser@0.1.1:
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
@ -6873,6 +6964,11 @@ packages:
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-in-ci@2.0.0:
resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==}
engines: {node: '>=20'}
hasBin: true
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@ -8262,6 +8358,10 @@ packages:
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
patch-console@2.0.0:
resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
path-data-parser@0.1.0:
resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==}
@ -8658,6 +8758,12 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-reconciler@0.33.0:
resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
engines: {node: '>=0.10.0'}
peerDependencies:
react: ^19.2.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@ -8738,6 +8844,10 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
react@19.2.3:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -8891,6 +9001,10 @@ packages:
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
hasBin: true
restore-cursor@4.0.0:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
@ -9001,6 +9115,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
screenfull@5.2.0:
resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==}
engines: {node: '>=0.10.0'}
@ -9308,6 +9425,10 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string-width@8.1.0:
resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==}
engines: {node: '>=20'}
string.prototype.codepointat@0.2.1:
resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==}
@ -10255,6 +10376,10 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
widest-line@5.0.0:
resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==}
engines: {node: '>=18'}
windows-release@6.1.0:
resolution: {integrity: sha512-1lOb3qdzw6OFmOzoY0nauhLG72TpWtb5qgYPiSh/62rjc1XidBSDio2qw0pwHh17VINF217ebIkZJdFLZFn9SA==}
engines: {node: '>=18'}
@ -10423,6 +10548,9 @@ packages:
resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
yoga-wasm-web@0.3.3:
resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==}
@ -10460,6 +10588,24 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zustand@5.0.9:
resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@ -10467,6 +10613,11 @@ snapshots:
'@adobe/css-tools@4.4.2': {}
'@alcalzone/ansi-tokenize@0.2.3':
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.0.0
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@ -11768,6 +11919,14 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
'@inkjs/ui@2.0.0(ink@6.6.0(@types/react@19.2.7)(react@19.2.3))':
dependencies:
chalk: 5.4.1
cli-spinners: 3.3.0
deepmerge: 4.3.1
figures: 6.1.0
ink: 6.6.0(@types/react@19.2.7)(react@19.2.3)
'@inquirer/external-editor@1.0.2(@types/node@24.2.1)':
dependencies:
chardet: 2.1.0
@ -13998,6 +14157,10 @@ snapshots:
'@types/prop-types': 15.7.14
csstype: 3.1.3
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 24.2.1
@ -14388,6 +14551,10 @@ snapshots:
dependencies:
environment: 1.1.0
ansi-escapes@7.2.0:
dependencies:
environment: 1.1.0
ansi-regex@5.0.1: {}
ansi-regex@6.1.0: {}
@ -14564,6 +14731,8 @@ snapshots:
asynckit@0.4.0: {}
auto-bind@5.0.1: {}
autoprefixer@10.4.21(postcss@8.5.4):
dependencies:
browserslist: 4.24.5
@ -14815,6 +14984,8 @@ snapshots:
chalk@5.4.1: {}
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@1.1.4: {}
@ -14908,17 +15079,30 @@ snapshots:
dependencies:
clsx: 2.1.1
cli-boxes@3.0.0: {}
cli-cursor@4.0.0:
dependencies:
restore-cursor: 4.0.0
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
cli-spinners@2.9.2: {}
cli-spinners@3.3.0: {}
cli-truncate@4.0.0:
dependencies:
slice-ansi: 5.0.0
string-width: 7.2.0
cli-truncate@5.1.1:
dependencies:
slice-ansi: 7.1.0
string-width: 8.1.0
client-only@0.0.1: {}
cliui@6.0.0:
@ -14974,6 +15158,10 @@ snapshots:
cockatiel@3.2.1: {}
code-excerpt@4.0.0:
dependencies:
convert-to-spaces: 2.0.1
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
@ -15055,6 +15243,8 @@ snapshots:
convert-source-map@2.0.0: {}
convert-to-spaces@2.0.1: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@ -15158,6 +15348,8 @@ snapshots:
csstype@3.1.3: {}
csstype@3.2.3: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
dependencies:
cose-base: 1.0.3
@ -15417,6 +15609,8 @@ snapshots:
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.0: {}
default-browser@5.2.1:
@ -15761,6 +15955,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.43.0: {}
esbuild-register@3.6.0(esbuild@0.25.9):
dependencies:
debug: 4.4.1(supports-color@8.1.1)
@ -16876,11 +17072,45 @@ snapshots:
indent-string@4.0.0: {}
indent-string@5.0.0: {}
inherits@2.0.4: {}
ini@1.3.8:
optional: true
ink@6.6.0(@types/react@19.2.7)(react@19.2.3):
dependencies:
'@alcalzone/ansi-tokenize': 0.2.3
ansi-escapes: 7.2.0
ansi-styles: 6.2.3
auto-bind: 5.0.1
chalk: 5.6.2
cli-boxes: 3.0.0
cli-cursor: 4.0.0
cli-truncate: 5.1.1
code-excerpt: 4.0.0
es-toolkit: 1.43.0
indent-string: 5.0.0
is-in-ci: 2.0.0
patch-console: 2.0.0
react: 19.2.3
react-reconciler: 0.33.0(react@19.2.3)
signal-exit: 3.0.7
slice-ansi: 7.1.0
stack-utils: 2.0.6
string-width: 8.1.0
type-fest: 4.41.0
widest-line: 5.0.0
wrap-ansi: 9.0.0
ws: 8.18.3
yoga-layout: 3.2.1
optionalDependencies:
'@types/react': 19.2.7
transitivePeerDependencies:
- bufferutil
- utf-8-validate
inline-style-parser@0.1.1: {}
inline-style-parser@0.2.4: {}
@ -17024,6 +17254,8 @@ snapshots:
is-hexadecimal@2.0.1: {}
is-in-ci@2.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
@ -18718,6 +18950,8 @@ snapshots:
partial-json@0.1.7: {}
patch-console@2.0.0: {}
path-data-parser@0.1.0: {}
path-exists@4.0.0: {}
@ -19112,6 +19346,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-reconciler@0.33.0(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react-refresh@0.17.0: {}
react-remark@2.1.0(react@18.3.1):
@ -19210,6 +19449,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
react@19.2.3: {}
read-cache@1.0.0:
dependencies:
pify: 2.3.0
@ -19447,6 +19688,11 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
restore-cursor@4.0.0:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.7
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
@ -19593,6 +19839,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
scheduler@0.27.0: {}
screenfull@5.2.0: {}
section-matter@1.0.0:
@ -19798,12 +20046,12 @@ snapshots:
slice-ansi@5.0.0:
dependencies:
ansi-styles: 6.2.1
ansi-styles: 6.2.3
is-fullwidth-code-point: 4.0.0
slice-ansi@7.1.0:
dependencies:
ansi-styles: 6.2.1
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.0.0
smart-buffer@4.2.0: {}
@ -19956,6 +20204,11 @@ snapshots:
get-east-asian-width: 1.3.0
strip-ansi: 7.1.2
string-width@8.1.0:
dependencies:
get-east-asian-width: 1.3.0
strip-ansi: 7.1.2
string.prototype.codepointat@0.2.1: {}
string.prototype.matchall@4.0.12:
@ -21155,6 +21408,10 @@ snapshots:
dependencies:
string-width: 4.2.3
widest-line@5.0.0:
dependencies:
string-width: 7.2.0
windows-release@6.1.0:
dependencies:
execa: 8.0.1
@ -21185,7 +21442,7 @@ snapshots:
wrap-ansi@9.0.0:
dependencies:
ansi-styles: 6.2.1
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.1.2
@ -21297,6 +21554,8 @@ snapshots:
yoctocolors@2.1.1: {}
yoga-layout@3.2.1: {}
yoga-wasm-web@0.3.3: {}
zip-stream@4.1.1:
@ -21334,4 +21593,9 @@ snapshots:
zod@3.25.76: {}
zustand@5.0.9(@types/react@19.2.7)(react@19.2.3):
optionalDependencies:
'@types/react': 19.2.7
react: 19.2.3
zwitch@2.0.4: {}

View file

@ -1916,16 +1916,16 @@ export class McpHub {
async dispose(): Promise<void> {
// Prevent multiple disposals
if (this.isDisposed) {
console.log("McpHub: Already disposed.")
return
}
console.log("McpHub: Disposing...")
this.isDisposed = true
// Clear all debounce timers
for (const timer of this.configChangeDebounceTimers.values()) {
clearTimeout(timer)
}
this.configChangeDebounceTimers.clear()
// Clear flag reset timer and reset programmatic update flag
@ -1933,9 +1933,10 @@ export class McpHub {
clearTimeout(this.flagResetTimer)
this.flagResetTimer = undefined
}
this.isProgrammaticUpdate = false
this.isProgrammaticUpdate = false
this.removeAllFileWatchers()
for (const connection of this.connections) {
try {
await this.deleteConnection(connection.server.name, connection.server.source)
@ -1943,15 +1944,19 @@ export class McpHub {
console.error(`Failed to close connection for ${connection.server.name}:`, error)
}
}
this.connections = []
if (this.settingsWatcher) {
this.settingsWatcher.dispose()
this.settingsWatcher = undefined
}
if (this.projectMcpWatcher) {
this.projectMcpWatcher.dispose()
this.projectMcpWatcher = undefined
}
this.disposables.forEach((d) => d.dispose())
}
}