From 88092ec95521163931cd87630efeb197bbb0ed9e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 6 Feb 2026 00:28:36 -0700 Subject: [PATCH 1/3] feat(cli): add ui-next Solid/OpenTUI runtime and build pipeline --- apps/cli/package.json | 10 + apps/cli/scripts/build-ui-next.ts | 56 + apps/cli/src/commands/cli/run.ts | 35 +- apps/cli/src/ui-next/app.tsx | 83 + .../component/__tests__/logo-data.test.ts | 26 + .../autocomplete/__tests__/triggers.test.ts | 290 ++++ .../ui-next/component/autocomplete/index.tsx | 162 ++ .../component/autocomplete/triggers.ts | 137 ++ apps/cli/src/ui-next/component/border.tsx | 16 + .../src/ui-next/component/help-overlay.tsx | 60 + apps/cli/src/ui-next/component/logo-data.ts | 56 + apps/cli/src/ui-next/component/logo.tsx | 19 + .../src/ui-next/component/prompt/index.tsx | 501 ++++++ apps/cli/src/ui-next/component/tips.tsx | 42 + .../context/__tests__/extension.test.ts | 1048 ++++++++++++ apps/cli/src/ui-next/context/exit.tsx | 55 + apps/cli/src/ui-next/context/extension.tsx | 616 +++++++ apps/cli/src/ui-next/context/helper.tsx | 30 + apps/cli/src/ui-next/context/keybind.tsx | 84 + apps/cli/src/ui-next/context/route.tsx | 25 + apps/cli/src/ui-next/context/theme.tsx | 122 ++ apps/cli/src/ui-next/context/toast.tsx | 47 + apps/cli/src/ui-next/main.tsx | 31 + apps/cli/src/ui-next/routes/home.tsx | 45 + .../cli/src/ui-next/routes/session/footer.tsx | 61 + .../cli/src/ui-next/routes/session/header.tsx | 50 + apps/cli/src/ui-next/routes/session/index.tsx | 242 +++ apps/cli/src/ui-next/types.ts | 121 ++ .../src/ui-next/ui/__tests__/spinner.test.ts | 44 + apps/cli/src/ui-next/ui/dialog.tsx | 55 + apps/cli/src/ui-next/ui/spinner.ts | 9 + apps/cli/src/ui-next/ui/toast.tsx | 33 + .../ui-next/util/__tests__/clipboard.test.ts | 105 ++ .../src/ui-next/util/__tests__/editor.test.ts | 132 ++ .../ui-next/util/__tests__/terminal.test.ts | 180 +++ apps/cli/src/ui-next/util/clipboard.ts | 14 + apps/cli/src/ui-next/util/editor.ts | 32 + apps/cli/src/ui-next/util/terminal.ts | 56 + apps/cli/tsconfig.json | 2 +- apps/cli/tsconfig.ui-next.json | 18 + apps/cli/tsup.config.ts | 2 +- pnpm-lock.yaml | 1416 +++++++++++++++-- 42 files changed, 6030 insertions(+), 138 deletions(-) create mode 100644 apps/cli/scripts/build-ui-next.ts create mode 100644 apps/cli/src/ui-next/app.tsx create mode 100644 apps/cli/src/ui-next/component/__tests__/logo-data.test.ts create mode 100644 apps/cli/src/ui-next/component/autocomplete/__tests__/triggers.test.ts create mode 100644 apps/cli/src/ui-next/component/autocomplete/index.tsx create mode 100644 apps/cli/src/ui-next/component/autocomplete/triggers.ts create mode 100644 apps/cli/src/ui-next/component/border.tsx create mode 100644 apps/cli/src/ui-next/component/help-overlay.tsx create mode 100644 apps/cli/src/ui-next/component/logo-data.ts create mode 100644 apps/cli/src/ui-next/component/logo.tsx create mode 100644 apps/cli/src/ui-next/component/prompt/index.tsx create mode 100644 apps/cli/src/ui-next/component/tips.tsx create mode 100644 apps/cli/src/ui-next/context/__tests__/extension.test.ts create mode 100644 apps/cli/src/ui-next/context/exit.tsx create mode 100644 apps/cli/src/ui-next/context/extension.tsx create mode 100644 apps/cli/src/ui-next/context/helper.tsx create mode 100644 apps/cli/src/ui-next/context/keybind.tsx create mode 100644 apps/cli/src/ui-next/context/route.tsx create mode 100644 apps/cli/src/ui-next/context/theme.tsx create mode 100644 apps/cli/src/ui-next/context/toast.tsx create mode 100644 apps/cli/src/ui-next/main.tsx create mode 100644 apps/cli/src/ui-next/routes/home.tsx create mode 100644 apps/cli/src/ui-next/routes/session/footer.tsx create mode 100644 apps/cli/src/ui-next/routes/session/header.tsx create mode 100644 apps/cli/src/ui-next/routes/session/index.tsx create mode 100644 apps/cli/src/ui-next/types.ts create mode 100644 apps/cli/src/ui-next/ui/__tests__/spinner.test.ts create mode 100644 apps/cli/src/ui-next/ui/dialog.tsx create mode 100644 apps/cli/src/ui-next/ui/spinner.ts create mode 100644 apps/cli/src/ui-next/ui/toast.tsx create mode 100644 apps/cli/src/ui-next/util/__tests__/clipboard.test.ts create mode 100644 apps/cli/src/ui-next/util/__tests__/editor.test.ts create mode 100644 apps/cli/src/ui-next/util/__tests__/terminal.test.ts create mode 100644 apps/cli/src/ui-next/util/clipboard.ts create mode 100644 apps/cli/src/ui-next/util/editor.ts create mode 100644 apps/cli/src/ui-next/util/terminal.ts create mode 100644 apps/cli/tsconfig.ui-next.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 23d34b8c7fc..74c22446f05 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,6 +14,7 @@ "check-types": "tsc --noEmit", "test": "vitest run", "build": "tsup", + "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", @@ -21,9 +22,14 @@ }, "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:^", "@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", @@ -31,16 +37,20 @@ "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" }, "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", 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..e85d08ca231 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,26 @@ 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 }, - ) + // 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") + + 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 + const { startTUI } = await import(tuiBundlePath) + + 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/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..f23595850ce --- /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 } 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 any to pass an unknown type + expect(getReplacementText("unknown" as any, "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/index.tsx b/apps/cli/src/ui-next/component/prompt/index.tsx new file mode 100644 index 00000000000..5873af5f88a --- /dev/null +++ b/apps/cli/src/ui-next/component/prompt/index.tsx @@ -0,0 +1,501 @@ +/** + * Main prompt input component for the SolidJS TUI. + * + * Uses opentui's native