diff --git a/apps/cli/package.json b/apps/cli/package.json index 23d34b8c7fc..8ccaf2899e3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -13,35 +13,44 @@ "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", "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts", "clean": "rimraf dist .turbo" }, "dependencies": { - "@inkjs/ui": "^2.0.0", + "@opentui/core": "^0.1.77", + "@opentui/solid": "^0.1.77", "@roo-code/core": "workspace:^", "@roo-code/types": "workspace:^", "@roo-code/vscode-shim": "workspace:^", + "@solid-primitives/event-bus": "^1.1.2", + "@solid-primitives/scheduled": "^1.5.2", "@trpc/client": "^11.8.1", "@vscode/ripgrep": "^1.15.9", "commander": "^12.1.0", "cross-spawn": "^7.0.6", "execa": "^9.5.2", "fuzzysort": "^3.1.0", - "ink": "^6.6.0", "p-wait-for": "^5.0.2", - "react": "^19.1.0", - "superjson": "^2.2.6", - "zustand": "^5.0.0" + "solid-js": "^1.9.11", + "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", - "ink-testing-library": "^4.0.0", + "babel-preset-solid": "^1.9.10", "rimraf": "^6.0.1", "tsup": "^8.4.0", "vitest": "^3.2.3" diff --git a/apps/cli/scripts/build-ui-next.ts b/apps/cli/scripts/build-ui-next.ts new file mode 100644 index 00000000000..9811504f625 --- /dev/null +++ b/apps/cli/scripts/build-ui-next.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env bun + +/** + * Build script for the SolidJS/opentui TUI. + * + * Uses Bun.build with the solid plugin to properly transform SolidJS JSX. + * + * Usage: + * cd apps/cli && bun scripts/build-ui-next.ts + * + * Output: + * dist/ui-next/main.js + */ + +import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" +import path from "path" + +const dir = path.resolve(import.meta.dir, "..") + +process.chdir(dir) + +const result = await Bun.build({ + entrypoints: ["./src/ui-next/main.tsx"], + outdir: "./dist/ui-next", + target: "bun", + plugins: [solidPlugin], + external: [ + // Keep native modules external + "@vscode/ripgrep", + "@anthropic-ai/sdk", + "@anthropic-ai/bedrock-sdk", + "@anthropic-ai/vertex-sdk", + ], + sourcemap: "external", +}) + +if (!result.success) { + console.error("Build failed:") + for (const msg of result.logs) { + console.error(msg) + } + process.exit(1) +} + +console.log(`Build succeeded: ${result.outputs.length} outputs`) +for (const output of result.outputs) { + const size = output.size + const sizeStr = + size > 1024 * 1024 + ? `${(size / (1024 * 1024)).toFixed(2)} MB` + : size > 1024 + ? `${(size / 1024).toFixed(2)} KB` + : `${size} B` + + console.log(` ${path.relative(dir, output.path)} (${sizeStr})`) +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 881107fb820..c952fa12e26 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -2,8 +2,6 @@ import fs from "fs" import path from "path" import { fileURLToPath } from "url" -import { createElement } from "react" - import { setLogger } from "@roo-code/vscode-shim" import { @@ -204,19 +202,34 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (isTuiEnabled) { try { - const { render } = await import("ink") - const { App } = await import("../../ui/App.js") - - render( - createElement(App, { - ...extensionHostOptions, - initialPrompt: prompt, - version: VERSION, - createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), - }), - // Handle Ctrl+C in App component for double-press exit. - { exitOnCtrlC: false }, - ) + const cliRoot = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..") + const tuiBundlePath = path.join(cliRoot, "dist", "ui-next", "main.js") + + if (!fs.existsSync(tuiBundlePath)) { + throw new Error( + `TUI bundle not found at: ${tuiBundlePath}\n` + + `Run 'cd apps/cli && bun scripts/build-ui-next.ts' to build it.`, + ) + } + + // Dynamic import the pre-built SolidJS/opentui TUI bundle + interface UINextModule { + startTUI: ( + props: ExtensionHostOptions & { + initialPrompt?: string + version: string + createExtensionHost: (opts: ExtensionHostOptions) => ExtensionHost + }, + ) => Promise + } + const { startTUI } = (await import(tuiBundlePath)) as UINextModule + + await startTUI({ + ...extensionHostOptions, + initialPrompt: prompt, + version: VERSION, + createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts), + }) } catch (error) { console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error)) 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 c346e60d6d0..00000000000 --- 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 c1224c8b1ec..f388895200b 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 792f38ee59d..00000000000 --- 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 15da68f540c..97c4a3a094f 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") - - return new Promise((resolve) => { - const onSelect = async (choice: OnboardingProviderChoice) => { - await saveSettings({ onboardingProviderChoice: choice }) - - app.unmount() - - console.log("") - - if (choice === OnboardingProviderChoice.Roo) { - const result = await login() - await saveSettings({ onboardingProviderChoice: choice }) - - 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 }) +// 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" + +interface MenuOption { + label: string + value: T +} + +/** + * 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 + + 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 + } + + let selectedIndex = 0 + + 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`) + } + } + + 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 } } - const app = render(createElement(OnboardingScreen, { onSelect })) + 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 be3ff9484db..763871ba4d2 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/app.tsx b/apps/cli/src/ui-next/app.tsx new file mode 100644 index 00000000000..3f3b545d5aa --- /dev/null +++ b/apps/cli/src/ui-next/app.tsx @@ -0,0 +1,83 @@ +/** + * Main app component for the SolidJS/opentui TUI. + * Sets up the provider hierarchy and routes. + */ + +import { Switch, Match, ErrorBoundary } from "solid-js" +import type { ExtensionHostOptions, ExtensionHostInterface } from "../agent/index.js" + +import { ThemeProvider } from "./context/theme.js" +import { RouteProvider, useRoute } from "./context/route.js" +import { ExitProvider } from "./context/exit.js" +import { ToastProvider } from "./context/toast.js" +import { KeybindProvider } from "./context/keybind.js" +import { DialogProvider } from "./ui/dialog.js" +import { ExtensionProvider, type ExtensionContextProps } from "./context/extension.js" + +import { Home } from "./routes/home.js" +import { Session } from "./routes/session/index.js" + +export interface TUIAppProps extends ExtensionHostOptions { + initialPrompt?: string + version: string + createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface +} + +function AppRouter(props: { version: string; mode: string; provider: string; model: string }) { + const route = useRoute() + + return ( + + + + + + + + + ) +} + +function ErrorFallback(props: { error: Error }) { + return ( + + + Error: {props.error.message} + + Press Ctrl+C to exit + + ) +} + +export function App(props: TUIAppProps) { + const extensionProps: ExtensionContextProps = { + options: props, + initialPrompt: props.initialPrompt, + createExtensionHost: props.createExtensionHost, + } + + return ( + }> + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/cli/src/ui-next/component/__tests__/logo-data.test.ts b/apps/cli/src/ui-next/component/__tests__/logo-data.test.ts new file mode 100644 index 00000000000..a4ffcb600c3 --- /dev/null +++ b/apps/cli/src/ui-next/component/__tests__/logo-data.test.ts @@ -0,0 +1,26 @@ +import { LOGO_COMPACT, LOGO_MINI, LOGO_WIDE, selectLogoForWidth } from "../logo-data.js" + +describe("selectLogoForWidth", () => { + it("returns wide logo for large terminals", () => { + expect(selectLogoForWidth(132)).toBe(LOGO_WIDE) + expect(selectLogoForWidth(200)).toBe(LOGO_WIDE) + }) + + it("returns compact logo for medium terminals", () => { + expect(selectLogoForWidth(112)).toBe(LOGO_COMPACT) + expect(selectLogoForWidth(131)).toBe(LOGO_COMPACT) + }) + + it("returns mini logo for narrow terminals", () => { + expect(selectLogoForWidth(111)).toBe(LOGO_MINI) + expect(selectLogoForWidth(80)).toBe(LOGO_MINI) + }) +}) + +describe("logo variant structure", () => { + it("contains multiline content in each variant", () => { + expect(LOGO_WIDE.split("\n").length).toBeGreaterThan(8) + expect(LOGO_COMPACT.split("\n").length).toBeGreaterThan(6) + expect(LOGO_MINI.split("\n").length).toBe(4) + }) +}) 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 new file mode 100644 index 00000000000..d8e1d2b381a --- /dev/null +++ b/apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for trigger detection logic. + */ + +import { detectTrigger, formatRelativeTime, truncateText, getReplacementText, type TriggerType } from "../triggers.js" + +describe("detectTrigger", () => { + it("returns null for empty input", () => { + expect(detectTrigger("")).toBeNull() + }) + + it("returns null for normal text", () => { + expect(detectTrigger("hello world")).toBeNull() + }) + + // ? — help trigger + describe("help trigger (?)", () => { + it("detects ? at line start", () => { + const result = detectTrigger("?") + expect(result).toEqual({ type: "help", query: "", triggerIndex: 0 }) + }) + + it("detects ? with query", () => { + const result = detectTrigger("?mod") + expect(result).toEqual({ type: "help", query: "mod", triggerIndex: 0 }) + }) + + it("returns null when ? has space in query", () => { + expect(detectTrigger("? something")).toBeNull() + }) + + it("detects ? with leading whitespace", () => { + const result = detectTrigger(" ?") + expect(result).toEqual({ type: "help", query: "", triggerIndex: 2 }) + }) + }) + + // / — slash command trigger + describe("slash command trigger (/)", () => { + it("detects / at line start", () => { + const result = detectTrigger("/") + expect(result).toEqual({ type: "slash", query: "", triggerIndex: 0 }) + }) + + it("detects /new", () => { + const result = detectTrigger("/new") + expect(result).toEqual({ type: "slash", query: "new", triggerIndex: 0 }) + }) + + it("returns null when slash command has space", () => { + expect(detectTrigger("/command arg")).toBeNull() + }) + }) + + // ! — mode trigger + describe("mode trigger (!)", () => { + it("detects ! at line start", () => { + const result = detectTrigger("!") + expect(result).toEqual({ type: "mode", query: "", triggerIndex: 0 }) + }) + + it("detects !code", () => { + const result = detectTrigger("!code") + expect(result).toEqual({ type: "mode", query: "code", triggerIndex: 0 }) + }) + + it("returns null when mode has space", () => { + expect(detectTrigger("!code stuff")).toBeNull() + }) + }) + + // # — history trigger + describe("history trigger (#)", () => { + it("detects # at line start", () => { + const result = detectTrigger("#") + expect(result).toEqual({ type: "history", query: "", triggerIndex: 0 }) + }) + + it("detects # with query including spaces", () => { + const result = detectTrigger("#fix bug") + expect(result).toEqual({ type: "history", query: "fix bug", triggerIndex: 0 }) + }) + }) + + // @ — file trigger + describe("file trigger (@)", () => { + it("detects @ anywhere in line", () => { + const result = detectTrigger("check @") + expect(result).toEqual({ type: "file", query: "", triggerIndex: 6 }) + }) + + it("detects @src", () => { + const result = detectTrigger("@src") + expect(result).toEqual({ type: "file", query: "src", triggerIndex: 0 }) + }) + + it("detects @ with text before", () => { + const result = detectTrigger("look at @file") + expect(result).toEqual({ type: "file", query: "file", triggerIndex: 8 }) + }) + + it("returns null when @ query has space", () => { + expect(detectTrigger("@some file")).toBeNull() + }) + + it("detects last @ in line with multiple @", () => { + const result = detectTrigger("@first @second") + expect(result).toEqual({ type: "file", query: "second", triggerIndex: 7 }) + }) + }) + + // Multi-line + describe("multi-line input", () => { + it("only examines last line", () => { + const result = detectTrigger("first line\n/cmd") + expect(result).toEqual({ type: "slash", query: "cmd", triggerIndex: 0 }) + }) + + it("returns null if last line has no trigger", () => { + const result = detectTrigger("/cmd\nnormal text") + expect(result).toBeNull() + }) + }) +}) + +describe("formatRelativeTime", () => { + it("returns 'just now' for recent times", () => { + expect(formatRelativeTime(Date.now() - 5000)).toBe("just now") + }) + + it("returns minutes for times within the hour", () => { + expect(formatRelativeTime(Date.now() - 5 * 60 * 1000)).toBe("5 mins ago") + }) + + it("returns '1 min ago' for single minute", () => { + expect(formatRelativeTime(Date.now() - 90 * 1000)).toBe("1 min ago") + }) + + it("returns hours for times within the day", () => { + expect(formatRelativeTime(Date.now() - 3 * 60 * 60 * 1000)).toBe("3 hours ago") + }) + + it("returns days for older times", () => { + expect(formatRelativeTime(Date.now() - 2 * 24 * 60 * 60 * 1000)).toBe("2 days ago") + }) +}) + +describe("truncateText", () => { + it("returns text unchanged if within limit", () => { + expect(truncateText("hello", 10)).toBe("hello") + }) + + it("truncates with ellipsis when too long", () => { + expect(truncateText("hello world", 6)).toBe("hello…") + }) + + it("returns exact length text unchanged", () => { + expect(truncateText("hello", 5)).toBe("hello") + }) +}) + +describe("getReplacementText", () => { + it("replaces slash command text", () => { + expect(getReplacementText("slash", "new", "/ne", 0)).toBe("/new ") + }) + + it("replaces file trigger text", () => { + expect(getReplacementText("file", "src/app.ts", "check @src", 6)).toBe("check @/src/app.ts ") + }) + + it("clears input for mode selection", () => { + expect(getReplacementText("mode", "code", "!co", 0)).toBe("") + }) + + it("clears input for history selection", () => { + expect(getReplacementText("history", "task-id", "#fix", 0)).toBe("") + }) + + it("returns value for help selection", () => { + expect(getReplacementText("help", "?modes", "?mo", 0)).toBe("?modes") + }) + + it("preserves text before trigger for file replacement", () => { + expect(getReplacementText("file", "components/App.tsx", "look at @comp", 8)).toBe( + "look at @/components/App.tsx ", + ) + }) + + it("handles unknown type by returning currentLine", () => { + // Cast to test the default branch with an unknown type + expect(getReplacementText("unknown" as TriggerType, "val", "current", 0)).toBe("current") + }) +}) + +// ================================================================ +// Additional edge case tests +// ================================================================ + +describe("detectTrigger — edge cases", () => { + it("returns null for only whitespace", () => { + expect(detectTrigger(" ")).toBeNull() + }) + + it("handles tab characters in leading whitespace", () => { + const result = detectTrigger("\t/cmd") + expect(result).toEqual({ type: "slash", query: "cmd", triggerIndex: 1 }) + }) + + it("handles multiple empty lines before trigger", () => { + const result = detectTrigger("\n\n\n/test") + expect(result).toEqual({ type: "slash", query: "test", triggerIndex: 0 }) + }) + + it("does not detect trigger mid-word (e.g., email addresses with @)", () => { + const result = detectTrigger("user@domain.com") + // This should detect @ but query has a dot which is fine, no space + expect(result).toEqual({ type: "file", query: "domain.com", triggerIndex: 4 }) + }) + + it("handles @ trigger preceded by newline", () => { + const result = detectTrigger("some text\n@file") + expect(result).toEqual({ type: "file", query: "file", triggerIndex: 0 }) + }) + + it("handles trigger at very end after space — no trigger", () => { + // "/ " has a space after the slash so query includes " " → no trigger + expect(detectTrigger("/ ")).toBeNull() + }) + + it("handles empty last line in multi-line input", () => { + expect(detectTrigger("first\n")).toBeNull() + }) + + it("prioritizes help (?) over other triggers on same line", () => { + const result = detectTrigger("?") + expect(result?.type).toBe("help") + }) + + it("does not detect @ when query has space", () => { + expect(detectTrigger("look at @some file")).toBeNull() + }) + + it("handles # trigger with empty query", () => { + const result = detectTrigger("#") + expect(result).toEqual({ type: "history", query: "", triggerIndex: 0 }) + }) + + it("handles # trigger with leading whitespace and query", () => { + const result = detectTrigger(" #search term") + expect(result).toEqual({ type: "history", query: "search term", triggerIndex: 2 }) + }) +}) + +describe("formatRelativeTime — edge cases", () => { + it("returns '1 hour ago' for exactly 1 hour", () => { + expect(formatRelativeTime(Date.now() - 60 * 60 * 1000)).toBe("1 hour ago") + }) + + it("returns '1 day ago' for exactly 1 day", () => { + expect(formatRelativeTime(Date.now() - 24 * 60 * 60 * 1000)).toBe("1 day ago") + }) + + it("returns 'just now' for timestamps in the future", () => { + // Future timestamps result in negative diff, Math.floor yields 0 or negative + expect(formatRelativeTime(Date.now() + 10000)).toBe("just now") + }) + + it("returns 'just now' for exactly now", () => { + expect(formatRelativeTime(Date.now())).toBe("just now") + }) +}) + +describe("truncateText — edge cases", () => { + it("handles maxLength of 1", () => { + expect(truncateText("hello", 1)).toBe("…") + }) + + it("handles maxLength of 0", () => { + // Edge case: maxLength 0 means text.length (5) > 0, substring(0, -1) = "" + expect(truncateText("hello", 0)).toBe("…") + }) + + it("handles empty text", () => { + expect(truncateText("", 5)).toBe("") + }) + + it("handles text exactly maxLength - 1", () => { + expect(truncateText("hell", 5)).toBe("hell") + }) +}) diff --git a/apps/cli/src/ui-next/component/autocomplete/index.tsx b/apps/cli/src/ui-next/component/autocomplete/index.tsx new file mode 100644 index 00000000000..68c8ab12b85 --- /dev/null +++ b/apps/cli/src/ui-next/component/autocomplete/index.tsx @@ -0,0 +1,162 @@ +/** + * Generic autocomplete overlay component. + * + * Renders a floating list of items above the prompt, with keyboard navigation. + * Used for slash commands, file search, mode switching, help, and history. + */ + +import { For, Show, createSignal, createEffect, createMemo, on } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import type { KeyEvent } from "@opentui/core" +import { useTheme } from "../../context/theme.js" + +export interface AutocompleteItem { + key: string + label: string + /** Secondary text shown dimmed after label */ + description?: string + /** Left icon/emoji */ + icon?: string + /** Right-side metadata text */ + meta?: string +} + +export interface AutocompleteOverlayProps { + /** Whether the overlay is visible */ + visible: boolean + /** Items to display */ + items: AutocompleteItem[] + /** Title shown at top of overlay */ + title?: string + /** Message when no items match */ + emptyMessage?: string + /** Max items to show before scrolling */ + maxVisible?: number + /** Called when an item is selected (Enter) */ + onSelect: (item: AutocompleteItem, index: number) => void + /** Called when overlay is dismissed (Escape) */ + onDismiss: () => void +} + +export function AutocompleteOverlay(props: AutocompleteOverlayProps) { + const { theme } = useTheme() + const [selectedIndex, setSelectedIndex] = createSignal(0) + const maxVisible = () => props.maxVisible ?? 10 + + // Reset selection when items change + createEffect( + on( + () => props.items.length, + () => setSelectedIndex(0), + ), + ) + + // Reset selection when visibility changes + createEffect( + on( + () => props.visible, + (visible) => { + if (visible) setSelectedIndex(0) + }, + ), + ) + + // Keyboard navigation + useKeyboard((event: KeyEvent) => { + if (!props.visible) return + + if (event.name === "up") { + setSelectedIndex((i) => Math.max(0, i - 1)) + } else if (event.name === "down") { + setSelectedIndex((i) => Math.min(props.items.length - 1, i + 1)) + } else if (event.name === "return") { + const item = props.items[selectedIndex()] + if (item) { + props.onSelect(item, selectedIndex()) + } + } else if (event.name === "escape") { + props.onDismiss() + } + }) + + // Calculate visible window for scrolling + const visibleWindow = createMemo(() => { + const max = maxVisible() + const items = props.items + const selected = selectedIndex() + + if (items.length <= max) { + return { start: 0, end: items.length } + } + + // Keep selected item centered in window + let start = Math.max(0, selected - Math.floor(max / 2)) + const end = Math.min(items.length, start + max) + start = Math.max(0, end - max) + + return { start, end } + }) + + const visibleItems = createMemo(() => { + const { start, end } = visibleWindow() + return props.items.slice(start, end).map((item, i) => ({ + item, + globalIndex: start + i, + })) + }) + + return ( + + + {/* Title */} + + + {props.title} + + + + {/* Items or empty message */} + 0} + fallback={ + + {props.emptyMessage ?? "No results"} + + }> + + {({ item, globalIndex }) => { + const isSelected = () => globalIndex === selectedIndex() + return ( + + + {isSelected() ? "❯ " : " "} + {item.icon ? `${item.icon} ` : ""} + {item.label} + + + {item.description} + + + {item.meta} + + + ) + }} + + + + {/* Scroll indicator */} + maxVisible()}> + + {selectedIndex() + 1}/{props.items.length} ↑↓ navigate • Enter select • Esc dismiss + + + + + ) +} diff --git a/apps/cli/src/ui-next/component/autocomplete/triggers.ts b/apps/cli/src/ui-next/component/autocomplete/triggers.ts new file mode 100644 index 00000000000..5453fc9f1c0 --- /dev/null +++ b/apps/cli/src/ui-next/component/autocomplete/triggers.ts @@ -0,0 +1,137 @@ +/** + * Trigger detection logic for autocomplete overlays. + * + * Pure functions that detect when a trigger character is typed + * and extract the search query. No JSX or rendering logic here. + * + * Trigger characters: + * - `/` — slash commands (line-start only) + * - `@` — file search (anywhere in line) + * - `!` — mode switcher (line-start only) + * - `?` — help shortcuts (line-start only) + * - `#` — task history (line-start only) + */ + +export type TriggerType = "slash" | "file" | "mode" | "help" | "history" + +export interface TriggerDetection { + type: TriggerType + query: string + triggerIndex: number +} + +/** + * Detect which trigger (if any) is active given the current input text. + * Returns the first matching trigger, prioritized by specificity. + * + * @param text — the current full text of the input + * @returns TriggerDetection or null if no trigger active + */ +export function detectTrigger(text: string): TriggerDetection | null { + // We only examine the current line (last line of multi-line input) + const lines = text.split("\n") + const line = lines[lines.length - 1] ?? "" + + // Line-start triggers: check if line starts with trigger char (after optional whitespace) + const trimmed = line.trimStart() + const leadingWhitespace = line.length - trimmed.length + + // ? — help (line-start, first char only) + if (trimmed.startsWith("?")) { + const query = trimmed.substring(1) + if (!query.includes(" ")) { + return { type: "help", query, triggerIndex: leadingWhitespace } + } + } + + // / — slash commands (line-start) + if (trimmed.startsWith("/")) { + const query = trimmed.substring(1) + if (!query.includes(" ")) { + return { type: "slash", query, triggerIndex: leadingWhitespace } + } + } + + // ! — mode switcher (line-start) + if (trimmed.startsWith("!")) { + const query = trimmed.substring(1) + if (!query.includes(" ")) { + return { type: "mode", query, triggerIndex: leadingWhitespace } + } + } + + // # — task history (line-start) + if (trimmed.startsWith("#")) { + const query = trimmed.substring(1) + // Note: no space check for history — allow full search + return { type: "history", query, triggerIndex: leadingWhitespace } + } + + // @ — file search (anywhere in line) + const atIndex = line.lastIndexOf("@") + if (atIndex !== -1) { + const query = line.substring(atIndex + 1) + if (!query.includes(" ")) { + return { type: "file", query, triggerIndex: atIndex } + } + } + + return null +} + +/** + * Format a timestamp as a relative time string (e.g., "2 days ago"). + */ +export function formatRelativeTime(ts: number): string { + const now = Date.now() + const diff = now - ts + + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago` + if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago` + if (minutes > 0) return minutes === 1 ? "1 min ago" : `${minutes} mins ago` + return "just now" +} + +/** + * Truncate text to a max length with ellipsis. + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text + return text.substring(0, maxLength - 1) + "…" +} + +/** + * Generate replacement text for a trigger selection. + * Replaces the trigger + query with the selected value. + */ +export function getReplacementText( + type: TriggerType, + selectedValue: string, + currentLine: string, + triggerIndex: number, +): string { + const before = currentLine.substring(0, triggerIndex) + + switch (type) { + case "slash": + return `${before}/${selectedValue} ` + case "file": + return `${before}@/${selectedValue} ` + case "mode": + // Mode switching clears the input + return "" + case "help": + // Help selection inserts the trigger char or clears + return selectedValue + case "history": + // History selection clears the input (task is resumed) + return "" + default: + return currentLine + } +} diff --git a/apps/cli/src/ui-next/component/border.tsx b/apps/cli/src/ui-next/component/border.tsx new file mode 100644 index 00000000000..cf8f48272b8 --- /dev/null +++ b/apps/cli/src/ui-next/component/border.tsx @@ -0,0 +1,16 @@ +/** + * Horizontal line/border component. + */ + +import { useTheme } from "../context/theme.js" +import { useTerminalDimensions } from "@opentui/solid" + +export function HorizontalLine(props: { active?: boolean }) { + const { theme } = useTheme() + const dims = useTerminalDimensions() + + const color = () => (props.active ? theme.borderActive : theme.border) + const width = () => Math.max(dims().width - 2, 10) + + return {"─".repeat(width())} +} diff --git a/apps/cli/src/ui-next/component/help-overlay.tsx b/apps/cli/src/ui-next/component/help-overlay.tsx new file mode 100644 index 00000000000..a9a9b24b559 --- /dev/null +++ b/apps/cli/src/ui-next/component/help-overlay.tsx @@ -0,0 +1,60 @@ +/** + * Help overlay showing keyboard shortcuts. + * Activated by typing `?` as the first character in an empty input. + */ + +import { For, Show } from "solid-js" +import { useTheme } from "../context/theme.js" + +export interface HelpShortcut { + shortcut: string + description: string +} + +const SHORTCUTS: HelpShortcut[] = [ + { shortcut: "/", description: "Slash commands" }, + { shortcut: "@", description: "Mention files" }, + { shortcut: "!", description: "Switch mode" }, + { shortcut: "#", description: "Resume task from history" }, + { shortcut: "Esc", description: "Cancel current task" }, + { shortcut: "Tab", description: "Toggle focus" }, + { shortcut: "Ctrl+M", description: "Cycle modes" }, + { shortcut: "Ctrl+C", description: "Exit (press twice)" }, + { shortcut: "Alt+Enter", description: "New line" }, +] + +export interface HelpOverlayProps { + visible: boolean +} + +export function HelpOverlay(props: HelpOverlayProps) { + const { theme } = useTheme() + + return ( + + + + Keyboard Shortcuts + + + {(item) => ( + + + {" "} + {item.shortcut.padEnd(12)} + + {item.description} + + )} + + {" "}Press Esc to dismiss + + + ) +} diff --git a/apps/cli/src/ui-next/component/logo-data.ts b/apps/cli/src/ui-next/component/logo-data.ts new file mode 100644 index 00000000000..9579ee67bf5 --- /dev/null +++ b/apps/cli/src/ui-next/component/logo-data.ts @@ -0,0 +1,56 @@ +/** + * ASCII logo variants for the home screen. + * Generated from the canonical Roo Code SVG, then manually tuned. + */ + +/** + * High-fidelity logo tuned for wide terminals. + * ~129 columns. + */ +export const LOGO_WIDE = [ + " ▄▄███▄▄▄ ▄▄", + " ▄█████████████▄▄██▄", + " ▄▄█████████████████████ ▄▄▄▄▄▄", + " ▄█████████████████████████▄ ██████████▄ ▄█████████▄ █████████▄ ▄████████ ▄████████▄ ██████████ ██████████", + "▄▄▄▄▄▄█████▀▀ ▀█████████████▀▀ ▀▀██ ████▀▀▀████ ████▀▀▀████ ████▀▀▀████ ████▀▀▀▀▀▀ ███▀▀▀▀████ ████▀▀▀████ ████▀▀▀▀▀▀", + "▀▀▀▀▀▀▀▀▀ ▀███████▀▀ ████▄▄▄████ ████ ████ ████ ████ ████ ███ ████ ████ ████ █████████", + " █████▀ ██████████▀ ████ ████ ████ ████ ████ ███ ████ ████ ████ ████████▀", + " ▀████ ████▀▀███▄ ████▄▄▄████ ████▄▄▄████ ▀███▄▄▄▄▄▄ ███▄▄▄▄████ ████▄▄▄████ ████▄▄▄▄▄▄", + " ▀███▄ ████ ▀████ ▀█████████▀ ▀████████▀ ▀████████ ▀████████▀ █████████▀ ██████████", + " ███▄", + " ▀███", + " ▀▀▀", +].join("\n") + +/** + * Compact logo tuned for medium terminals. + * ~111 columns. + */ +export const LOGO_COMPACT = [ + " ▄█▄▄▄ ▄▄", + " ▄▄██████████▄▄██▄", + " ▄███████████████████ ▄▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄", + " ▄▄██████████████████████ █████████▄ █████████ ▄████████ ▄███████ █████████ █████████ █████████", + "▄▄███████▀ ▀█████████▀▀ ▀▀▀ ████ ████ ███▀▀▀███ ███▀▀▀███ ████▀▀▀ ███▀▀▀███ ███▀▀▀████ ████▄▄▄▄", + " █████▀ ██████████ ███ ███ ███ ███ ████ ███ ███ ███ ████ ████████", + " ████ ████▀████ ███▄▄▄███ ███▄▄▄███ ████▄▄▄▄ ███▄▄▄███ ███▄▄▄████ ████▄▄▄▄▄", + " ▀███ ████ ████ ▀███████▀ ▀███████▀ ▀███████ ▀███████▀ █████████ █████████", + " ▀███", + " ███", +].join("\n") + +/** + * Minimal fallback for narrow terminals. + */ +export const LOGO_MINI = [ + " ▄▄█▄▄ ██████ ███████", + " ▄███████▄ ██ ██ ██ ██", + " ▀███▀ ██████ ██ ██", + " ▀▀ ██ ██ ███████", +].join("\n") + +export function selectLogoForWidth(width: number): string { + if (width >= 132) return LOGO_WIDE + if (width >= 112) return LOGO_COMPACT + return LOGO_MINI +} diff --git a/apps/cli/src/ui-next/component/logo.tsx b/apps/cli/src/ui-next/component/logo.tsx new file mode 100644 index 00000000000..5d2b4302478 --- /dev/null +++ b/apps/cli/src/ui-next/component/logo.tsx @@ -0,0 +1,19 @@ +/** + * Roo Code logo — kangaroo + ROOCODE block letters. + * Hand-tuned from SVG render to match the brand logo. + */ + +import { useTheme } from "../context/theme.js" +import { selectLogoForWidth } from "./logo-data.js" + +export function Logo() { + const { theme } = useTheme() + const terminalWidth = process.stdout.columns ?? 120 + const logo = selectLogoForWidth(terminalWidth) + + return ( + + {logo} + + ) +} 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 00000000000..b11802fca93 --- /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 00000000000..7a5424bbc11 --- /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 00000000000..95647a31d31 --- /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 new file mode 100644 index 00000000000..fbb64d957aa --- /dev/null +++ b/apps/cli/src/ui-next/component/prompt/index.tsx @@ -0,0 +1,215 @@ +/** Prompt input component — orchestrator wiring keybindings, autocomplete, and keyboard modules. */ + +import { createSignal, createEffect, createMemo, on, onCleanup } from "solid-js" +import { type TextareaRenderable } from "@opentui/core" +import { useKeyboard } from "@opentui/solid" + +import { useTheme } from "../../context/theme.js" +import { useExit } from "../../context/exit.js" +import { useToast } from "../../context/toast.js" +import { useExtension } from "../../context/extension.js" +import { AutocompleteOverlay, type AutocompleteItem } from "../autocomplete/index.js" +import { HelpOverlay } from "../help-overlay.js" +import { detectTrigger, type TriggerDetection } from "../autocomplete/triggers.js" +import { PROMPT_KEYBINDINGS } from "./keybindings.js" +import { + buildSlashCommandItems, + buildModeItems, + buildHistoryItems, + triggerFileSearch, +} from "./autocomplete-builders.js" +import { handleAutocompleteSelect, handleAutocompleteDismiss } from "./autocomplete-handlers.js" +import { getAutocompleteTitle, getAutocompleteEmpty, shouldShowAutocomplete } from "./autocomplete-memos.js" +import { createPromptKeyboardHandler } from "./keyboard-handler.js" + +export interface PromptRef { + getText: () => string + clear: () => void + focus: () => void +} + +export interface PromptProps { + placeholder?: string + onSubmit: (text: string) => void + isActive?: boolean + prefix?: string + ref?: (ref: PromptRef) => void + enableTriggers?: boolean +} + +export function Prompt(props: PromptProps) { + const { theme } = useTheme() + const exit = useExit() + const toast = useToast() + const ext = useExtension() + let textareaRef: TextareaRenderable | undefined + + const [currentText, setCurrentText] = createSignal("") + const [activeTrigger, setActiveTrigger] = createSignal(null) + const [showHelp, setShowHelp] = createSignal(false) + const [autocompleteItems, setAutocompleteItems] = createSignal([]) + const [pendingExit, setPendingExit] = createSignal(false) + const exitTimerRef: { current: ReturnType | undefined } = { current: undefined } + let fileSearchTimer: ReturnType | undefined + + onCleanup(() => { + if (exitTimerRef.current) clearTimeout(exitTimerRef.current) + if (fileSearchTimer) clearTimeout(fileSearchTimer) + }) + + // Trigger detection — runs whenever text changes + createEffect( + on(currentText, (text) => { + if (!props.enableTriggers) return + const trigger = detectTrigger(text) + if (!trigger) { + setActiveTrigger(null) + setShowHelp(false) + setAutocompleteItems([]) + return + } + setActiveTrigger(trigger) + switch (trigger.type) { + case "help": + setShowHelp(true) + setAutocompleteItems([]) + break + case "slash": + setShowHelp(false) + setAutocompleteItems(buildSlashCommandItems(trigger.query, ext.state.allSlashCommands)) + break + case "file": + setShowHelp(false) + fileSearchTimer = triggerFileSearch(trigger.query, ext.searchFiles.bind(ext), fileSearchTimer) + break + case "mode": + setShowHelp(false) + setAutocompleteItems(buildModeItems(trigger.query, ext.state.availableModes)) + break + case "history": + setShowHelp(false) + setAutocompleteItems(buildHistoryItems(trigger.query, ext.state.taskHistory)) + break + } + }), + ) + + // Refresh file search results when they arrive from extension + createEffect( + on( + () => ext.state.fileSearchResults, + (results) => { + const trigger = activeTrigger() + if (trigger?.type === "file" && results.length > 0) { + setAutocompleteItems( + results.map((r) => ({ key: r.key || r.path, label: r.path || r.label, icon: "📄" })), + ) + } + }, + ), + ) + + // Autocomplete handler contexts + const selectCtx = { + get textareaRef() { + return textareaRef + }, + currentText, + activeTrigger, + setActiveTrigger, + setShowHelp, + setAutocompleteItems, + sendToExtension: ext.sendToExtension.bind(ext), + toastInfo: toast.info, + } + const dismissCtx = { + get textareaRef() { + return textareaRef + }, + setActiveTrigger, + setShowHelp, + setAutocompleteItems, + } + const onSelect = (item: AutocompleteItem, index: number) => handleAutocompleteSelect(item, index, selectCtx) + const onDismiss = () => handleAutocompleteDismiss(dismissCtx) + + // Keyboard handler + useKeyboard( + createPromptKeyboardHandler({ + isActive: () => props.isActive ?? false, + hasOverlay: () => activeTrigger() !== null || showHelp(), + dismissOverlay: onDismiss, + isLoading: () => ext.state.isLoading, + availableModes: () => ext.state.availableModes || [], + currentMode: () => ext.state.currentMode, + sendToExtension: ext.sendToExtension.bind(ext), + toastInfo: toast.info, + toastWarning: toast.warning, + exit, + pendingExit, + setPendingExit, + exitTimer: exitTimerRef, + }), + ) + + function handleSubmit() { + if (!textareaRef) return + const text = textareaRef.plainText.trim() + if (!text) return + if (activeTrigger() || showHelp()) return + props.onSubmit(text) + textareaRef.clear() + setCurrentText("") + } + + function handleContentChange() { + if (!textareaRef) return + setCurrentText(textareaRef.plainText) + } + + const autocompleteTitle = createMemo(() => getAutocompleteTitle(activeTrigger())) + const autocompleteEmpty = createMemo(() => getAutocompleteEmpty(activeTrigger())) + const showAutocomplete = createMemo(() => shouldShowAutocomplete(activeTrigger())) + + return ( + + + + + + {props.prefix ?? "› "} + +