mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-11 22:51:26 +00:00
Add a TUI
This commit is contained in:
parent
a81438de3e
commit
53ad2e1d6a
23 changed files with 3075 additions and 64 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
238
apps/cli/src/__tests__/historyStorage.test.ts
Normal file
238
apps/cli/src/__tests__/historyStorage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
171
apps/cli/src/__tests__/useInputHistory.test.ts
Normal file
171
apps/cli/src/__tests__/useInputHistory.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
929
apps/cli/src/ui/App.tsx
Normal 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}>> </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}>> </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}` : ""}`
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal file
79
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal 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)
|
||||
58
apps/cli/src/ui/components/Header.tsx
Normal file
58
apps/cli/src/ui/components/Header.tsx
Normal 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)
|
||||
125
apps/cli/src/ui/components/HistoryTextInput.tsx
Normal file
125
apps/cli/src/ui/components/HistoryTextInput.tsx
Normal 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
|
||||
41
apps/cli/src/ui/components/LoadingText.tsx
Normal file
41
apps/cli/src/ui/components/LoadingText.tsx
Normal 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)
|
||||
162
apps/cli/src/ui/components/TextInput.tsx
Normal file
162
apps/cli/src/ui/components/TextInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
195
apps/cli/src/ui/hooks/useInputHistory.ts
Normal file
195
apps/cli/src/ui/hooks/useInputHistory.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
58
apps/cli/src/ui/hooks/useTerminalSize.ts
Normal file
58
apps/cli/src/ui/hooks/useTerminalSize.ts
Normal 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
21
apps/cli/src/ui/index.ts
Normal 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
84
apps/cli/src/ui/store.ts
Normal 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
73
apps/cli/src/ui/types.ts
Normal 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"
|
||||
78
apps/cli/src/ui/utils/theme.ts
Normal file
78
apps/cli/src/ui/utils/theme.ts
Normal 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
|
||||
131
apps/cli/src/utils/historyStorage.ts
Normal file
131
apps/cli/src/utils/historyStorage.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
270
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue