diff --git a/apps/cli/package.json b/apps/cli/package.json
index 23d34b8c7f..74c22446f0 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 0000000000..9811504f62
--- /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 881107fb82..e85d08ca23 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")
+ // 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")
- 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 },
- )
+ 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 0000000000..3f3b545d5a
--- /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 0000000000..a4ffcb600c
--- /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 0000000000..f23595850c
--- /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 0000000000..68c8ab12b8
--- /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 0000000000..5453fc9f1c
--- /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 0000000000..cf8f48272b
--- /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 0000000000..a9a9b24b55
--- /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 0000000000..9579ee67bf
--- /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 0000000000..5d2b430247
--- /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 0000000000..5873af5f88
--- /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