From 524709de5f4a113c79d9efac3b1d415eed78d350 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 6 Feb 2026 20:26:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20complete=20SolidJS/OpenTUI=20migra?= =?UTF-8?q?tion=20=E2=80=94=20remove=20React/Ink=20legacy=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete entire src/ui/ directory (~80 files of React/Ink components, hooks, stores) - Remove react, ink, @inkjs/ui, zustand, @types/react, ink-testing-library deps - Move @opentui/core-darwin-arm64 to optionalDependencies with all 5 platform variants - Remove unused opentui-spinner dependency - Chain build scripts: 'build' now runs tsup && bun build-ui-next.ts - Clean tsup.config.ts: restore clean:true, remove React JSX config - Clean tsconfig.json: remove jsx/jsxImportSource React settings Architecture improvements: - Decompose prompt God Component (502→194 LOC + 5 extracted modules) - Extract shared extension-logic.ts (295 LOC pure functions) - Eliminate test harness duplication, fix missing todo handling bug - Relocate tools.ts from ui/utils/ to lib/utils/ - Port onboarding from React/Ink to pure Node.js terminal prompts - Type dynamic import with UINextModule interface - Replace all 'as any' casts with precise types - Fix context-window.ts broken @/ui/store.js import - Fix all lint warnings (unused imports, prefer-const, no-control-regex) Verification: tsc clean, 350 tests pass, zero React/Ink/Zustand references, lint clean --- apps/cli/package.json | 19 +- apps/cli/src/commands/cli/run.ts | 12 +- .../cli/src/lib/utils/__tests__/input.test.ts | 128 ---- apps/cli/src/lib/utils/context-window.ts | 6 +- apps/cli/src/lib/utils/input.ts | 122 ---- apps/cli/src/lib/utils/onboarding.ts | 159 ++++- apps/cli/src/{ui => lib}/utils/tools.ts | 2 +- .../autocomplete/__tests__/triggers.test.ts | 6 +- .../component/prompt/autocomplete-builders.ts | 125 ++++ .../component/prompt/autocomplete-handlers.ts | 105 +++ .../component/prompt/autocomplete-memos.ts | 51 ++ .../src/ui-next/component/prompt/index.tsx | 418 ++---------- .../ui-next/component/prompt/keybindings.ts | 13 + .../component/prompt/keyboard-handler.ts | 108 +++ .../context/__tests__/extension.test.ts | 430 +++++------- .../src/ui-next/context/extension-logic.ts | 390 +++++++++++ apps/cli/src/ui-next/context/extension.tsx | 395 ++++------- apps/cli/src/ui-next/routes/session/index.tsx | 4 +- .../ui-next/util/__tests__/clipboard.test.ts | 22 +- .../ui-next/util/__tests__/terminal.test.ts | 8 +- apps/cli/src/ui-next/util/terminal.ts | 26 +- apps/cli/src/ui/App.tsx | 617 ------------------ apps/cli/src/ui/__tests__/store.test.ts | 279 -------- .../cli/src/ui/components/ChatHistoryItem.tsx | 252 ------- apps/cli/src/ui/components/Header.tsx | 74 --- apps/cli/src/ui/components/HorizontalLine.tsx | 14 - apps/cli/src/ui/components/Icon.tsx | 174 ----- apps/cli/src/ui/components/LoadingText.tsx | 41 -- apps/cli/src/ui/components/MetricsDisplay.tsx | 68 -- .../src/ui/components/MultilineTextInput.tsx | 493 -------------- apps/cli/src/ui/components/ProgressBar.tsx | 61 -- apps/cli/src/ui/components/ScrollArea.tsx | 398 ----------- .../cli/src/ui/components/ScrollIndicator.tsx | 26 - apps/cli/src/ui/components/ToastDisplay.tsx | 56 -- .../src/ui/components/TodoChangeDisplay.tsx | 142 ---- apps/cli/src/ui/components/TodoDisplay.tsx | 163 ----- .../__tests__/ChatHistoryItem.test.tsx | 385 ----------- .../src/ui/components/__tests__/Icon.test.tsx | 162 ----- .../__tests__/ToastDisplay.test.tsx | 86 --- .../__tests__/TodoChangeDisplay.test.tsx | 149 ----- .../components/__tests__/TodoDisplay.test.tsx | 152 ----- .../autocomplete/AutocompleteInput.tsx | 321 --------- .../components/autocomplete/PickerSelect.tsx | 189 ------ .../src/ui/components/autocomplete/index.ts | 41 -- .../autocomplete/triggers/FileTrigger.tsx | 140 ---- .../autocomplete/triggers/HelpTrigger.tsx | 109 ---- .../autocomplete/triggers/HistoryTrigger.tsx | 193 ------ .../autocomplete/triggers/ModeTrigger.tsx | 109 ---- .../triggers/SlashCommandTrigger.tsx | 129 ---- .../triggers/__tests__/FileTrigger.test.tsx | 270 -------- .../triggers/__tests__/HelpTrigger.test.tsx | 169 ----- .../__tests__/HistoryTrigger.test.tsx | 275 -------- .../triggers/__tests__/ModeTrigger.test.tsx | 160 ----- .../__tests__/SlashCommandTrigger.test.tsx | 156 ----- .../components/autocomplete/triggers/index.ts | 19 - .../src/ui/components/autocomplete/types.ts | 154 ----- .../autocomplete/useAutocompletePicker.ts | 411 ------------ .../onboarding/OnboardingScreen.tsx | 28 - .../cli/src/ui/components/onboarding/index.ts | 1 - .../src/ui/components/tools/BrowserTool.tsx | 87 --- .../src/ui/components/tools/CommandTool.tsx | 50 -- .../ui/components/tools/CompletionTool.tsx | 40 -- .../src/ui/components/tools/FileReadTool.tsx | 131 ---- .../src/ui/components/tools/FileWriteTool.tsx | 165 ----- .../src/ui/components/tools/GenericTool.tsx | 93 --- apps/cli/src/ui/components/tools/ModeTool.tsx | 28 - .../src/ui/components/tools/SearchTool.tsx | 113 ---- .../tools/__tests__/CommandTool.test.tsx | 164 ----- apps/cli/src/ui/components/tools/index.ts | 63 -- apps/cli/src/ui/components/tools/types.ts | 44 -- apps/cli/src/ui/components/tools/utils.ts | 220 ------- apps/cli/src/ui/hooks/TerminalSizeContext.tsx | 38 -- .../src/ui/hooks/__tests__/useToast.test.ts | 190 ------ apps/cli/src/ui/hooks/index.ts | 22 - apps/cli/src/ui/hooks/useExtensionHost.ts | 153 ----- apps/cli/src/ui/hooks/useFocusManagement.ts | 85 --- apps/cli/src/ui/hooks/useFollowupCountdown.ts | 112 ---- apps/cli/src/ui/hooks/useGlobalInput.ts | 170 ----- apps/cli/src/ui/hooks/useInputHistory.ts | 127 ---- apps/cli/src/ui/hooks/useMessageHandlers.ts | 410 ------------ apps/cli/src/ui/hooks/usePickerHandlers.ts | 168 ----- apps/cli/src/ui/hooks/useTaskSubmit.ts | 183 ------ apps/cli/src/ui/hooks/useTerminalSize.ts | 59 -- apps/cli/src/ui/hooks/useToast.ts | 196 ------ apps/cli/src/ui/store.ts | 295 --------- apps/cli/src/ui/stores/uiStateStore.ts | 87 --- apps/cli/src/ui/theme.ts | 79 --- apps/cli/src/ui/types.ts | 123 ---- apps/cli/src/ui/utils/index.ts | 2 - apps/cli/src/ui/utils/views.ts | 52 -- apps/cli/tsconfig.json | 2 - apps/cli/tsup.config.ts | 9 +- 92 files changed, 1324 insertions(+), 11651 deletions(-) delete mode 100644 apps/cli/src/lib/utils/__tests__/input.test.ts delete mode 100644 apps/cli/src/lib/utils/input.ts rename apps/cli/src/{ui => lib}/utils/tools.ts (99%) create mode 100644 apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts create mode 100644 apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts create mode 100644 apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts create mode 100644 apps/cli/src/ui-next/component/prompt/keybindings.ts create mode 100644 apps/cli/src/ui-next/component/prompt/keyboard-handler.ts create mode 100644 apps/cli/src/ui-next/context/extension-logic.ts delete mode 100644 apps/cli/src/ui/App.tsx delete mode 100644 apps/cli/src/ui/__tests__/store.test.ts delete mode 100644 apps/cli/src/ui/components/ChatHistoryItem.tsx delete mode 100644 apps/cli/src/ui/components/Header.tsx delete mode 100644 apps/cli/src/ui/components/HorizontalLine.tsx delete mode 100644 apps/cli/src/ui/components/Icon.tsx delete mode 100644 apps/cli/src/ui/components/LoadingText.tsx delete mode 100644 apps/cli/src/ui/components/MetricsDisplay.tsx delete mode 100644 apps/cli/src/ui/components/MultilineTextInput.tsx delete mode 100644 apps/cli/src/ui/components/ProgressBar.tsx delete mode 100644 apps/cli/src/ui/components/ScrollArea.tsx delete mode 100644 apps/cli/src/ui/components/ScrollIndicator.tsx delete mode 100644 apps/cli/src/ui/components/ToastDisplay.tsx delete mode 100644 apps/cli/src/ui/components/TodoChangeDisplay.tsx delete mode 100644 apps/cli/src/ui/components/TodoDisplay.tsx delete mode 100644 apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx delete mode 100644 apps/cli/src/ui/components/__tests__/Icon.test.tsx delete mode 100644 apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx delete mode 100644 apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx delete mode 100644 apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/PickerSelect.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/index.ts delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/HistoryTrigger.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/SlashCommandTrigger.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/__tests__/FileTrigger.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/__tests__/HelpTrigger.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/__tests__/HistoryTrigger.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/__tests__/ModeTrigger.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/__tests__/SlashCommandTrigger.test.tsx delete mode 100644 apps/cli/src/ui/components/autocomplete/triggers/index.ts delete mode 100644 apps/cli/src/ui/components/autocomplete/types.ts delete mode 100644 apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts delete mode 100644 apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx delete mode 100644 apps/cli/src/ui/components/onboarding/index.ts delete mode 100644 apps/cli/src/ui/components/tools/BrowserTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/CommandTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/CompletionTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/FileReadTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/FileWriteTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/GenericTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/ModeTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/SearchTool.tsx delete mode 100644 apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx delete mode 100644 apps/cli/src/ui/components/tools/index.ts delete mode 100644 apps/cli/src/ui/components/tools/types.ts delete mode 100644 apps/cli/src/ui/components/tools/utils.ts delete mode 100644 apps/cli/src/ui/hooks/TerminalSizeContext.tsx delete mode 100644 apps/cli/src/ui/hooks/__tests__/useToast.test.ts delete mode 100644 apps/cli/src/ui/hooks/index.ts delete mode 100644 apps/cli/src/ui/hooks/useExtensionHost.ts delete mode 100644 apps/cli/src/ui/hooks/useFocusManagement.ts delete mode 100644 apps/cli/src/ui/hooks/useFollowupCountdown.ts delete mode 100644 apps/cli/src/ui/hooks/useGlobalInput.ts delete mode 100644 apps/cli/src/ui/hooks/useInputHistory.ts delete mode 100644 apps/cli/src/ui/hooks/useMessageHandlers.ts delete mode 100644 apps/cli/src/ui/hooks/usePickerHandlers.ts delete mode 100644 apps/cli/src/ui/hooks/useTaskSubmit.ts delete mode 100644 apps/cli/src/ui/hooks/useTerminalSize.ts delete mode 100644 apps/cli/src/ui/hooks/useToast.ts delete mode 100644 apps/cli/src/ui/store.ts delete mode 100644 apps/cli/src/ui/stores/uiStateStore.ts delete mode 100644 apps/cli/src/ui/theme.ts delete mode 100644 apps/cli/src/ui/types.ts delete mode 100644 apps/cli/src/ui/utils/index.ts delete mode 100644 apps/cli/src/ui/utils/views.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 74c22446f0..8ccaf2899e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,7 +13,7 @@ "lint": "eslint src --ext .ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", - "build": "tsup", + "build": "tsup && bun scripts/build-ui-next.ts", "build:ui-next": "bun scripts/build-ui-next.ts", "build:extension": "pnpm --filter roo-cline bundle", "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y", @@ -21,9 +21,7 @@ "clean": "rimraf dist .turbo" }, "dependencies": { - "@inkjs/ui": "^2.0.0", "@opentui/core": "^0.1.77", - "@opentui/core-darwin-arm64": "^0.1.77", "@opentui/solid": "^0.1.77", "@roo-code/core": "workspace:^", "@roo-code/types": "workspace:^", @@ -36,22 +34,23 @@ "cross-spawn": "^7.0.6", "execa": "^9.5.2", "fuzzysort": "^3.1.0", - "ink": "^6.6.0", - "opentui-spinner": "^0.0.6", "p-wait-for": "^5.0.2", - "react": "^19.1.0", "solid-js": "^1.9.11", - "superjson": "^2.2.6", - "zustand": "^5.0.0" + "superjson": "^2.2.6" + }, + "optionalDependencies": { + "@opentui/core-darwin-arm64": "^0.1.77", + "@opentui/core-darwin-x64": "^0.1.77", + "@opentui/core-linux-x64": "^0.1.77", + "@opentui/core-linux-arm64": "^0.1.77", + "@opentui/core-win32-x64": "^0.1.77" }, "devDependencies": { "@babel/core": "^7.29.0", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", "@types/node": "^24.1.0", - "@types/react": "^19.1.6", "babel-preset-solid": "^1.9.10", - "ink-testing-library": "^4.0.0", "rimraf": "^6.0.1", "tsup": "^8.4.0", "vitest": "^3.2.3" diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index e85d08ca23..c952fa12e2 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -202,7 +202,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (isTuiEnabled) { try { - // Resolve the ui-next bundle path relative to CLI package root const cliRoot = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..") const tuiBundlePath = path.join(cliRoot, "dist", "ui-next", "main.js") @@ -214,7 +213,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption } // Dynamic import the pre-built SolidJS/opentui TUI bundle - const { startTUI } = await import(tuiBundlePath) + interface UINextModule { + startTUI: ( + props: ExtensionHostOptions & { + initialPrompt?: string + version: string + createExtensionHost: (opts: ExtensionHostOptions) => ExtensionHost + }, + ) => Promise + } + const { startTUI } = (await import(tuiBundlePath)) as UINextModule await startTUI({ ...extensionHostOptions, diff --git a/apps/cli/src/lib/utils/__tests__/input.test.ts b/apps/cli/src/lib/utils/__tests__/input.test.ts deleted file mode 100644 index c346e60d6d..0000000000 --- a/apps/cli/src/lib/utils/__tests__/input.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { Key } from "ink" - -import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js" - -function createKey(overrides: Partial = {}): Key { - return { - upArrow: false, - downArrow: false, - leftArrow: false, - rightArrow: false, - pageDown: false, - pageUp: false, - home: false, - end: false, - return: false, - escape: false, - ctrl: false, - shift: false, - tab: false, - backspace: false, - delete: false, - meta: false, - ...overrides, - } -} - -describe("globalInputSequences", () => { - describe("GLOBAL_INPUT_SEQUENCES registry", () => { - it("should have ctrl-c registered", () => { - const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c") - expect(seq).toBeDefined() - expect(seq?.description).toContain("Exit") - }) - - it("should have ctrl-m registered", () => { - const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m") - expect(seq).toBeDefined() - expect(seq?.description).toContain("mode") - }) - }) - - describe("isGlobalInputSequence", () => { - describe("Ctrl+C detection", () => { - it("should match standard Ctrl+C", () => { - const result = isGlobalInputSequence("c", createKey({ ctrl: true })) - expect(result).toBeDefined() - expect(result?.id).toBe("ctrl-c") - }) - - it("should not match plain 'c' key", () => { - const result = isGlobalInputSequence("c", createKey()) - expect(result).toBeUndefined() - }) - }) - - describe("Ctrl+M detection", () => { - it("should match standard Ctrl+M", () => { - const result = isGlobalInputSequence("m", createKey({ ctrl: true })) - expect(result).toBeDefined() - expect(result?.id).toBe("ctrl-m") - }) - - it("should match CSI u encoding for Ctrl+M", () => { - const result = isGlobalInputSequence("\x1b[109;5u", createKey()) - expect(result).toBeDefined() - expect(result?.id).toBe("ctrl-m") - }) - - it("should match input ending with CSI u sequence", () => { - const result = isGlobalInputSequence("[109;5u", createKey()) - expect(result).toBeDefined() - expect(result?.id).toBe("ctrl-m") - }) - - it("should not match plain 'm' key", () => { - const result = isGlobalInputSequence("m", createKey()) - expect(result).toBeUndefined() - }) - }) - - it("should return undefined for non-global sequences", () => { - const result = isGlobalInputSequence("a", createKey()) - expect(result).toBeUndefined() - }) - - it("should return undefined for regular text input", () => { - const result = isGlobalInputSequence("hello", createKey()) - expect(result).toBeUndefined() - }) - }) - - describe("matchesGlobalSequence", () => { - it("should return true for matching sequence ID", () => { - const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c") - expect(result).toBe(true) - }) - - it("should return false for non-matching sequence ID", () => { - const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m") - expect(result).toBe(false) - }) - - it("should return false for non-existent sequence ID", () => { - const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent") - expect(result).toBe(false) - }) - - it("should match ctrl-m with CSI u encoding", () => { - const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m") - expect(result).toBe(true) - }) - }) - - describe("extensibility", () => { - it("should have unique IDs for all sequences", () => { - const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id) - const uniqueIds = new Set(ids) - expect(uniqueIds.size).toBe(ids.length) - }) - - it("should have descriptions for all sequences", () => { - for (const seq of GLOBAL_INPUT_SEQUENCES) { - expect(seq.description).toBeTruthy() - expect(seq.description.length).toBeGreaterThan(0) - } - }) - }) -}) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index c1224c8b1e..f388895200 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -1,6 +1,10 @@ import type { ProviderSettings } from "@roo-code/types" -import type { RouterModels } from "@/ui/store.js" +/** + * Map of provider name → model ID → model info (including context window). + * Previously imported from the old React/Ink UI store; now defined locally. + */ +export type RouterModels = Record> const DEFAULT_CONTEXT_WINDOW = 200_000 diff --git a/apps/cli/src/lib/utils/input.ts b/apps/cli/src/lib/utils/input.ts deleted file mode 100644 index 792f38ee59..0000000000 --- a/apps/cli/src/lib/utils/input.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Global Input Sequences Registry - * - * This module centralizes the definition of input sequences that should be - * handled at the App level (or other top-level components) and ignored by - * child components like MultilineTextInput. - * - * When adding new global shortcuts: - * 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES - * 2. The App.tsx useInput handler should check for and handle the sequence - * 3. Child components automatically ignore these via isGlobalInputSequence() - */ - -import type { Key } from "ink" - -/** - * Definition of a global input sequence - */ -export interface GlobalInputSequence { - /** Unique identifier for the sequence */ - id: string - /** Human-readable description */ - description: string - /** - * Matcher function - returns true if the input matches this sequence. - * @param input - The raw input string from useInput - * @param key - The parsed key object from useInput - */ - matches: (input: string, key: Key) => boolean -} - -/** - * Registry of all global input sequences that should be handled at the App level - * and ignored by child components (like MultilineTextInput). - * - * Add new global shortcuts here to ensure they're properly handled throughout - * the application. - */ -export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [ - { - id: "ctrl-c", - description: "Exit application (with confirmation)", - matches: (input, key) => key.ctrl && input === "c", - }, - { - id: "ctrl-m", - description: "Cycle through modes", - matches: (input, key) => { - // Standard Ctrl+M detection - if (key.ctrl && input === "m") return true - // CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol) - // 109 = 'm' ASCII code, 5 = Ctrl modifier - if (input === "\x1b[109;5u") return true - if (input.endsWith("[109;5u")) return true - return false - }, - }, - { - id: "ctrl-t", - description: "Toggle TODO list viewer", - matches: (input, key) => { - // Standard Ctrl+T detection - if (key.ctrl && input === "t") return true - // CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol) - // 116 = 't' ASCII code, 5 = Ctrl modifier - if (input === "\x1b[116;5u") return true - if (input.endsWith("[116;5u")) return true - return false - }, - }, - // Add more global sequences here as needed: - // { - // id: "ctrl-n", - // description: "New task", - // matches: (input, key) => key.ctrl && input === "n", - // }, -] - -/** - * Check if an input matches any global input sequence. - * - * Use this in child components (like MultilineTextInput) to determine - * if input should be ignored because it will be handled by a parent component. - * - * @param input - The raw input string from useInput - * @param key - The parsed key object from useInput - * @returns The matching GlobalInputSequence, or undefined if no match - * - * @example - * ```tsx - * useInput((input, key) => { - * // Ignore inputs handled at App level - * if (isGlobalInputSequence(input, key)) { - * return - * } - * // Handle component-specific input... - * }) - * ``` - */ -export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined { - return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key)) -} - -/** - * Check if an input matches a specific global input sequence by ID. - * - * @param input - The raw input string from useInput - * @param key - The parsed key object from useInput - * @param id - The sequence ID to check for - * @returns true if the input matches the specified sequence - * - * @example - * ```tsx - * if (matchesGlobalSequence(input, key, "ctrl-m")) { - * // Handle mode cycling - * } - * ``` - */ -export function matchesGlobalSequence(input: string, key: Key, id: string): boolean { - const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id) - return seq ? seq.matches(input, key) : false -} diff --git a/apps/cli/src/lib/utils/onboarding.ts b/apps/cli/src/lib/utils/onboarding.ts index 15da68f540..97c4a3a094 100644 --- a/apps/cli/src/lib/utils/onboarding.ts +++ b/apps/cli/src/lib/utils/onboarding.ts @@ -1,38 +1,149 @@ -import { createElement } from "react" +import { createInterface } from "node:readline" -import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js" +import { type OnboardingResult, OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js" import { login } from "@/commands/index.js" import { saveSettings } from "@/lib/storage/index.js" -export async function runOnboarding(): Promise { - const { render } = await import("ink") - const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js") +// ANSI helpers +const CYAN = "\x1b[36m" +const BOLD = "\x1b[1m" +const DIM = "\x1b[2m" +const RESET = "\x1b[0m" +const HIDE_CURSOR = "\x1b[?25l" +const SHOW_CURSOR = "\x1b[?25h" - return new Promise((resolve) => { - const onSelect = async (choice: OnboardingProviderChoice) => { - await saveSettings({ onboardingProviderChoice: choice }) +interface MenuOption { + label: string + value: T +} - app.unmount() +/** + * Render a terminal select menu with arrow-key navigation. + * Returns the selected value when the user presses Enter. + */ +function terminalSelect(prompt: string, options: MenuOption[]): Promise { + return new Promise((resolve, reject) => { + const { stdin, stdout } = process - console.log("") + if (!stdin.isTTY) { + // Non-interactive fallback: use readline to read a number + const rl = createInterface({ input: stdin, output: stdout }) + stdout.write(`${prompt}\n`) + options.forEach((opt, i) => stdout.write(` ${i + 1}) ${opt.label}\n`)) + rl.question("Enter choice (number): ", (answer) => { + rl.close() + const idx = parseInt(answer, 10) - 1 + const selected = options[idx] + if (idx >= 0 && idx < options.length && selected) { + resolve(selected.value) + } else { + reject(new Error(`Invalid choice: ${answer}`)) + } + }) + return + } - if (choice === OnboardingProviderChoice.Roo) { - const result = await login() - await saveSettings({ onboardingProviderChoice: choice }) + let selectedIndex = 0 - resolve({ - choice: OnboardingProviderChoice.Roo, - token: result.success ? result.token : undefined, - skipped: false, - }) - } else { - console.log("Using your own API key.") - console.log("Set your API key via --api-key or environment variable.") - console.log("") - resolve({ choice: OnboardingProviderChoice.Byok, skipped: false }) + function render() { + // Move cursor up to overwrite previous render (except on first render) + const lines = options.length + if (rendered) { + stdout.write(`\x1b[${lines}A`) + } + for (const [i, opt] of options.entries()) { + const prefix = i === selectedIndex ? `${CYAN}❯${RESET} ` : " " + const label = i === selectedIndex ? `${BOLD}${opt.label}${RESET}` : `${DIM}${opt.label}${RESET}` + stdout.write(`\x1b[2K${prefix}${label}\n`) } } - const app = render(createElement(OnboardingScreen, { onSelect })) + let rendered = false + + function onData(data: Buffer) { + const key = data.toString() + + // Up arrow: \x1b[A or k + if (key === "\x1b[A" || key === "k") { + selectedIndex = (selectedIndex - 1 + options.length) % options.length + render() + return + } + + // Down arrow: \x1b[B or j + if (key === "\x1b[B" || key === "j") { + selectedIndex = (selectedIndex + 1) % options.length + render() + return + } + + // Enter + if (key === "\r" || key === "\n") { + cleanup() + const selected = options[selectedIndex] + if (selected) { + resolve(selected.value) + } + return + } + + // Ctrl+C + if (key === "\x03") { + cleanup() + reject(new Error("User cancelled")) + return + } + } + + function cleanup() { + stdin.removeListener("data", onData) + stdin.setRawMode(false) + stdin.pause() + stdout.write(SHOW_CURSOR) + } + + // Setup raw mode for keypress detection + stdout.write(HIDE_CURSOR) + stdout.write(`${prompt}\n`) + stdin.setRawMode(true) + stdin.resume() + stdin.on("data", onData) + + render() + rendered = true }) } + +export async function runOnboarding(): Promise { + // Display ASCII art header + process.stdout.write(`\n${BOLD}${CYAN}${ASCII_ROO}${RESET}\n\n`) + + const choice = await terminalSelect( + `${DIM}Welcome! How would you like to connect to an LLM provider?${RESET}\n`, + [ + { label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo }, + { label: "Bring your own API key", value: OnboardingProviderChoice.Byok }, + ], + ) + + await saveSettings({ onboardingProviderChoice: choice }) + + console.log("") + + if (choice === OnboardingProviderChoice.Roo) { + const result = await login() + await saveSettings({ onboardingProviderChoice: choice }) + + return { + choice: OnboardingProviderChoice.Roo, + token: result.success ? result.token : undefined, + skipped: false, + } + } + + console.log("Using your own API key.") + console.log("Set your API key via --api-key or environment variable.") + console.log("") + + return { choice: OnboardingProviderChoice.Byok, skipped: false } +} diff --git a/apps/cli/src/ui/utils/tools.ts b/apps/cli/src/lib/utils/tools.ts similarity index 99% rename from apps/cli/src/ui/utils/tools.ts rename to apps/cli/src/lib/utils/tools.ts index be3ff9484d..763871ba4d 100644 --- a/apps/cli/src/ui/utils/tools.ts +++ b/apps/cli/src/lib/utils/tools.ts @@ -1,6 +1,6 @@ import type { TodoItem } from "@roo-code/types" -import type { ToolData } from "../types.js" +import type { ToolData } from "../../ui-next/types.js" /** * Extract structured ToolData from parsed tool JSON diff --git a/apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts b/apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts index f23595850c..d8e1d2b381 100644 --- a/apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts +++ b/apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts @@ -2,7 +2,7 @@ * Tests for trigger detection logic. */ -import { detectTrigger, formatRelativeTime, truncateText, getReplacementText } from "../triggers.js" +import { detectTrigger, formatRelativeTime, truncateText, getReplacementText, type TriggerType } from "../triggers.js" describe("detectTrigger", () => { it("returns null for empty input", () => { @@ -187,8 +187,8 @@ describe("getReplacementText", () => { }) it("handles unknown type by returning currentLine", () => { - // Cast to any to pass an unknown type - expect(getReplacementText("unknown" as any, "val", "current", 0)).toBe("current") + // Cast to test the default branch with an unknown type + expect(getReplacementText("unknown" as TriggerType, "val", "current", 0)).toBe("current") }) }) diff --git a/apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts b/apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts new file mode 100644 index 0000000000..b11802fca9 --- /dev/null +++ b/apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts @@ -0,0 +1,125 @@ +/** + * Autocomplete item builder functions. + * + * Each function takes a search query and dependencies, then returns + * a list of autocomplete items. They do not own any state — the caller + * is responsible for setting the resulting items into signals. + */ + +import fuzzysort from "fuzzysort" + +import type { AutocompleteItem } from "../autocomplete/index.js" +import { formatRelativeTime, truncateText } from "../autocomplete/triggers.js" +import { getGlobalCommandsForAutocomplete } from "../../../lib/utils/commands.js" + +import type { SlashCommandResult, ModeResult, TaskHistoryItem } from "../../types.js" + +// ---------------------------------------------------------------- +// Slash commands +// ---------------------------------------------------------------- + +/** Build autocomplete items for slash commands (global CLI + extension). */ +export function buildSlashCommandItems(query: string, allSlashCommands: SlashCommandResult[]): AutocompleteItem[] { + const globalCmds = getGlobalCommandsForAutocomplete().map((c) => ({ + key: c.name, + label: `/${c.name}`, + description: c.description, + icon: c.action ? "⚙️" : "🌐", + })) + + const extCmds = (allSlashCommands || []).map((c) => ({ + key: c.key || c.label, + label: `/${c.label}`, + description: c.description, + icon: "⚡", + })) + + let all = [...globalCmds, ...extCmds] + + if (query.length > 0) { + const results = fuzzysort.go(query, all, { + key: "label", + limit: 20, + threshold: -10000, + }) + all = results.map((r) => r.obj) + } else { + all = all.slice(0, 20) + } + + return all +} + +// ---------------------------------------------------------------- +// Modes +// ---------------------------------------------------------------- + +/** Build autocomplete items for mode switching. */ +export function buildModeItems(query: string, availableModes: ModeResult[]): AutocompleteItem[] { + let modes = (availableModes || []).map((m) => ({ + key: m.key || m.slug, + label: m.label, + description: m.slug, + icon: "🔧", + })) + + if (query.length > 0) { + const results = fuzzysort.go(query, modes, { + key: "label", + limit: 20, + threshold: -10000, + }) + modes = results.map((r) => r.obj) + } + + return modes +} + +// ---------------------------------------------------------------- +// History +// ---------------------------------------------------------------- + +/** Build autocomplete items for task history. */ +export function buildHistoryItems(query: string, taskHistory: TaskHistoryItem[]): AutocompleteItem[] { + let history = (taskHistory || []) + .sort((a, b) => b.ts - a.ts) + .map((h) => ({ + key: h.id, + label: truncateText(h.task.replace(/\n/g, " "), 55), + meta: formatRelativeTime(h.ts), + icon: h.status === "completed" ? "✓" : h.status === "active" ? "●" : "○", + })) + + if (query.length > 0) { + const results = fuzzysort.go(query, history, { + key: "label", + limit: 15, + threshold: -10000, + }) + history = results.map((r) => r.obj) + } else { + history = history.slice(0, 15) + } + + return history +} + +// ---------------------------------------------------------------- +// File search +// ---------------------------------------------------------------- + +/** + * Triggers a debounced file search. + * + * Returns a new timer handle. The caller must store and clear it on cleanup. + */ +export function triggerFileSearch( + query: string, + searchFiles: (q: string) => void, + existingTimer: ReturnType | undefined, +): ReturnType { + if (existingTimer) clearTimeout(existingTimer) + return setTimeout(() => { + searchFiles(query) + }, 150) +} diff --git a/apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts b/apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts new file mode 100644 index 0000000000..7a5424bbc1 --- /dev/null +++ b/apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts @@ -0,0 +1,105 @@ +/** + * Autocomplete selection and dismissal handlers. + * + * Pure functions that execute the side-effects of selecting or dismissing + * an autocomplete item: replacing text, switching modes, resuming tasks, etc. + */ + +import { batch } from "solid-js" +import type { TextareaRenderable } from "@opentui/core" +import type { WebviewMessage } from "@roo-code/types" + +import type { AutocompleteItem } from "../autocomplete/index.js" +import { getReplacementText, type TriggerDetection } from "../autocomplete/triggers.js" + +/** Dependencies required by the autocomplete select handler. */ +export interface AutocompleteSelectContext { + textareaRef: TextareaRenderable | undefined + currentText: () => string + activeTrigger: () => TriggerDetection | null + setActiveTrigger: (v: TriggerDetection | null) => void + setShowHelp: (v: boolean) => void + setAutocompleteItems: (v: AutocompleteItem[]) => void + sendToExtension: (msg: WebviewMessage) => void + toastInfo: (msg: string) => void +} + +/** Handle the user selecting an item from the autocomplete overlay. */ +export function handleAutocompleteSelect(item: AutocompleteItem, _index: number, ctx: AutocompleteSelectContext): void { + const trigger = ctx.activeTrigger() + if (!trigger || !ctx.textareaRef) return + + switch (trigger.type) { + case "slash": { + // Replace trigger text with selected command + const cmdName = item.label.startsWith("/") ? item.label.substring(1) : item.label + const replacement = getReplacementText("slash", cmdName, ctx.currentText(), trigger.triggerIndex) + ctx.textareaRef.clear() + if (replacement) ctx.textareaRef.insertText(replacement) + break + } + case "file": { + // Replace trigger text with file path + const replacement = getReplacementText("file", item.label, ctx.currentText(), trigger.triggerIndex) + ctx.textareaRef.clear() + if (replacement) ctx.textareaRef.insertText(replacement) + break + } + case "mode": { + // Switch mode via extension + const modeSlug = item.description || item.key + ctx.sendToExtension({ type: "mode", text: modeSlug }) + ctx.toastInfo(`Switched to ${item.label}`) + ctx.textareaRef.clear() + break + } + case "history": { + // Resume task from history + const taskId = item.key + ctx.sendToExtension({ type: "showTaskWithId", text: taskId }) + ctx.toastInfo("Resuming task...") + ctx.textareaRef.clear() + break + } + case "help": { + // Help items insert their trigger char + const shortcut = item.label.trim() + ctx.textareaRef.clear() + if (["Esc", "Tab", "Ctrl+M", "Ctrl+C", "Alt+Enter"].includes(shortcut)) { + // Action shortcuts — just clear + } else { + // Trigger shortcuts — insert the trigger char + ctx.textareaRef.insertText(shortcut) + } + break + } + } + + // Clear autocomplete state + batch(() => { + ctx.setActiveTrigger(null) + ctx.setShowHelp(false) + ctx.setAutocompleteItems([]) + }) +} + +/** Dependencies required by the autocomplete dismiss handler. */ +export interface AutocompleteDismissContext { + textareaRef: TextareaRenderable | undefined + setActiveTrigger: (v: TriggerDetection | null) => void + setShowHelp: (v: boolean) => void + setAutocompleteItems: (v: AutocompleteItem[]) => void +} + +/** Handle dismissing the autocomplete overlay. */ +export function handleAutocompleteDismiss(ctx: AutocompleteDismissContext): void { + batch(() => { + ctx.setActiveTrigger(null) + ctx.setShowHelp(false) + ctx.setAutocompleteItems([]) + }) + // Clear trigger char from input + if (ctx.textareaRef) { + ctx.textareaRef.clear() + } +} diff --git a/apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts b/apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts new file mode 100644 index 0000000000..95647a31d3 --- /dev/null +++ b/apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts @@ -0,0 +1,51 @@ +/** + * Pure functions for computing autocomplete display state. + * + * Maps trigger types to UI strings (title, empty message) + * and determines autocomplete overlay visibility. + */ + +import type { TriggerDetection } from "../autocomplete/triggers.js" + +/** Maps a trigger type to the autocomplete overlay title. */ +export function getAutocompleteTitle(trigger: TriggerDetection | null): string { + if (!trigger) return "" + switch (trigger.type) { + case "slash": + return "Commands" + case "file": + return "Files" + case "mode": + return "Modes" + case "history": + return "Task History" + case "help": + return "Help" + default: + return "" + } +} + +/** Maps a trigger type to the empty-state text shown when no items match. */ +export function getAutocompleteEmpty(trigger: TriggerDetection | null): string { + if (!trigger) return "No results" + switch (trigger.type) { + case "slash": + return "No matching commands" + case "file": + return "No matching files" + case "mode": + return "No matching modes" + case "history": + return "No task history" + case "help": + return "No shortcuts" + default: + return "No results" + } +} + +/** Determines whether the autocomplete overlay should be visible (excludes help trigger). */ +export function shouldShowAutocomplete(trigger: TriggerDetection | null): boolean { + return trigger !== null && trigger.type !== "help" +} diff --git a/apps/cli/src/ui-next/component/prompt/index.tsx b/apps/cli/src/ui-next/component/prompt/index.tsx index 5873af5f88..fbb64d957a 100644 --- a/apps/cli/src/ui-next/component/prompt/index.tsx +++ b/apps/cli/src/ui-next/component/prompt/index.tsx @@ -1,16 +1,8 @@ -/** - * Main prompt input component for the SolidJS TUI. - * - * Uses opentui's native