feat(cli): add ui-next Solid/OpenTUI runtime and build pipeline

This commit is contained in:
Hannes Rudolph 2026-02-06 00:28:36 -07:00
parent 5b0897beb9
commit 88092ec955
42 changed files with 6029 additions and 137 deletions

View file

@ -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",

View file

@ -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})`)
}

View file

@ -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))

View file

@ -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 (
<Switch>
<Match when={route.data.type === "session"}>
<Session version={props.version} mode={props.mode} provider={props.provider} model={props.model} />
</Match>
<Match when={route.data.type === "home"}>
<Home />
</Match>
</Switch>
)
}
function ErrorFallback(props: { error: Error }) {
return (
<box flexDirection="column" padding={1}>
<text fg="#F92672" bold>
Error: {props.error.message}
</text>
<text fg="#5E7175">Press Ctrl+C to exit</text>
</box>
)
}
export function App(props: TUIAppProps) {
const extensionProps: ExtensionContextProps = {
options: props,
initialPrompt: props.initialPrompt,
createExtensionHost: props.createExtensionHost,
}
return (
<ErrorBoundary fallback={(err: Error) => <ErrorFallback error={err} />}>
<ExitProvider>
<ThemeProvider>
<ToastProvider>
<RouteProvider>
<KeybindProvider>
<DialogProvider>
<ExtensionProvider {...extensionProps}>
<AppRouter
version={props.version}
mode={props.mode}
provider={props.provider as string}
model={props.model}
/>
</ExtensionProvider>
</DialogProvider>
</KeybindProvider>
</RouteProvider>
</ToastProvider>
</ThemeProvider>
</ExitProvider>
</ErrorBoundary>
)
}

View file

@ -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)
})
})

View file

@ -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")
})
})

View file

@ -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 (
<Show when={props.visible}>
<box
flexDirection="column"
borderStyle="rounded"
borderColor={theme.borderActive}
maxHeight={maxVisible() + 3}
flexShrink={0}>
{/* Title */}
<Show when={props.title}>
<text fg={theme.primary} bold paddingLeft={1}>
{props.title}
</text>
</Show>
{/* Items or empty message */}
<Show
when={props.items.length > 0}
fallback={
<text fg={theme.dimText} paddingLeft={2}>
{props.emptyMessage ?? "No results"}
</text>
}>
<For each={visibleItems()}>
{({ item, globalIndex }) => {
const isSelected = () => globalIndex === selectedIndex()
return (
<box flexDirection="row" paddingLeft={1}>
<text fg={isSelected() ? theme.accent : theme.text} bold={isSelected()}>
{isSelected() ? " " : " "}
{item.icon ? `${item.icon} ` : ""}
{item.label}
</text>
<Show when={item.description}>
<text fg={theme.dimText}> {item.description}</text>
</Show>
<Show when={item.meta}>
<text fg={theme.textMuted}> {item.meta}</text>
</Show>
</box>
)
}}
</For>
</Show>
{/* Scroll indicator */}
<Show when={props.items.length > maxVisible()}>
<text fg={theme.dimText} paddingLeft={1}>
{selectedIndex() + 1}/{props.items.length} navigate Enter select Esc dismiss
</text>
</Show>
</box>
</Show>
)
}

View file

@ -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
}
}

View file

@ -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 <text fg={color()}>{"─".repeat(width())}</text>
}

View file

@ -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 (
<Show when={props.visible}>
<box
flexDirection="column"
borderStyle="rounded"
borderColor={theme.borderActive}
flexShrink={0}
paddingLeft={1}
paddingRight={1}>
<text fg={theme.primary} bold>
Keyboard Shortcuts
</text>
<For each={SHORTCUTS}>
{(item) => (
<box flexDirection="row">
<text fg={theme.accent} bold>
{" "}
{item.shortcut.padEnd(12)}
</text>
<text fg={theme.dimText}>{item.description}</text>
</box>
)}
</For>
<text fg={theme.dimText}>{" "}Press Esc to dismiss</text>
</box>
</Show>
)
}

View file

@ -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
}

View file

@ -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 (
<box flexDirection="column" alignItems="center">
<text fg={theme.text}>{logo}</text>
</box>
)
}

View file

@ -0,0 +1,501 @@
/**
* Main prompt input component for the SolidJS TUI.
*
* Uses opentui's native <textarea> renderable for text input.
* Handles trigger detection for autocomplete overlays (/, @, !, ?, #),
* global keyboard shortcuts (Esc to cancel, Ctrl+C to exit),
* and coordinates with the autocomplete overlay and help overlay.
*/
import { createSignal, createEffect, createMemo, on, onCleanup, Show, batch } from "solid-js"
import { type TextareaRenderable, type KeyBinding, type KeyEvent } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import fuzzysort from "fuzzysort"
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,
formatRelativeTime,
truncateText,
getReplacementText,
type TriggerType,
type TriggerDetection,
} from "../autocomplete/triggers.js"
import { getGlobalCommandsForAutocomplete } from "../../../lib/utils/commands.js"
/** Default key bindings: Enter submits, Option+Enter for newline */
const PROMPT_KEYBINDINGS: KeyBinding[] = [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
]
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
/** Enable trigger detection for autocomplete overlays */
enableTriggers?: boolean
}
export function Prompt(props: PromptProps) {
const { theme } = useTheme()
const exit = useExit()
const toast = useToast()
const ext = useExtension()
let textareaRef: TextareaRenderable | undefined
// ================================================================
// Autocomplete state
// ================================================================
const [currentText, setCurrentText] = createSignal("")
const [activeTrigger, setActiveTrigger] = createSignal<TriggerDetection | null>(null)
const [showHelp, setShowHelp] = createSignal(false)
const [autocompleteItems, setAutocompleteItems] = createSignal<AutocompleteItem[]>([])
// Ctrl+C double-press state
const [pendingExit, setPendingExit] = createSignal(false)
let exitTimer: ReturnType<typeof setTimeout> | undefined
// File search debounce
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (exitTimer) clearTimeout(exitTimer)
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)
updateSlashCommandItems(trigger.query)
break
case "file":
setShowHelp(false)
triggerFileSearch(trigger.query)
break
case "mode":
setShowHelp(false)
updateModeItems(trigger.query)
break
case "history":
setShowHelp(false)
updateHistoryItems(trigger.query)
break
}
}),
)
// ================================================================
// Update autocomplete items when extension state changes
// ================================================================
// 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) {
const items: AutocompleteItem[] = results.map((r) => ({
key: r.key || r.path,
label: r.path || r.label,
icon: "📄",
}))
setAutocompleteItems(items)
}
},
),
)
// ================================================================
// Autocomplete item builders
// ================================================================
function updateSlashCommandItems(query: string) {
// Merge global CLI commands with extension slash commands
const globalCmds = getGlobalCommandsForAutocomplete().map((c) => ({
key: c.name,
label: `/${c.name}`,
description: c.description,
icon: c.action ? "⚙️" : "🌐",
}))
const extCmds = (ext.state.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)
}
setAutocompleteItems(all)
}
function updateModeItems(query: string) {
let modes = (ext.state.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)
}
setAutocompleteItems(modes)
}
function updateHistoryItems(query: string) {
let history = (ext.state.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)
}
setAutocompleteItems(history)
}
function triggerFileSearch(query: string) {
// Debounce file search API calls
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchTimer = setTimeout(() => {
ext.searchFiles(query)
}, 150)
}
// ================================================================
// Autocomplete selection handler
// ================================================================
function handleAutocompleteSelect(item: AutocompleteItem, _index: number) {
const trigger = activeTrigger()
if (!trigger || !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, currentText(), trigger.triggerIndex)
textareaRef.clear()
if (replacement) textareaRef.insertText(replacement)
break
}
case "file": {
// Replace trigger text with file path
const replacement = getReplacementText("file", item.label, currentText(), trigger.triggerIndex)
textareaRef.clear()
if (replacement) textareaRef.insertText(replacement)
break
}
case "mode": {
// Switch mode via extension
const modeSlug = item.description || item.key
ext.sendToExtension({ type: "mode", text: modeSlug })
toast.info(`Switched to ${item.label}`)
textareaRef.clear()
break
}
case "history": {
// Resume task from history
const taskId = item.key
ext.sendToExtension({ type: "showTaskWithId", text: taskId })
toast.info("Resuming task...")
textareaRef.clear()
break
}
case "help": {
// Help items insert their trigger char
const shortcut = item.label.trim()
textareaRef.clear()
if (["Esc", "Tab", "Ctrl+M", "Ctrl+C", "Alt+Enter"].includes(shortcut)) {
// Action shortcuts — just clear
} else {
// Trigger shortcuts — insert the trigger char
textareaRef.insertText(shortcut)
}
break
}
}
// Clear autocomplete state
batch(() => {
setActiveTrigger(null)
setShowHelp(false)
setAutocompleteItems([])
})
}
function handleAutocompleteDismiss() {
batch(() => {
setActiveTrigger(null)
setShowHelp(false)
setAutocompleteItems([])
})
// Clear trigger char from input
if (textareaRef) {
textareaRef.clear()
}
}
// ================================================================
// Global keyboard handler
// ================================================================
useKeyboard((event: KeyEvent) => {
if (!props.isActive) return
// Only intercept when we're not in an autocomplete overlay
const hasOverlay = activeTrigger() !== null || showHelp()
// Esc — cancel task or dismiss overlay
if (event.name === "escape") {
if (hasOverlay) {
handleAutocompleteDismiss()
return
}
// Cancel current task when loading
if (ext.state.isLoading) {
ext.sendToExtension({ type: "cancelTask" })
toast.info("Cancelling task...")
return
}
}
// Ctrl+C — double-press to exit
if (event.name === "c" && event.ctrl) {
if (hasOverlay) {
handleAutocompleteDismiss()
return
}
if (pendingExit()) {
// Second press — exit
if (exitTimer) clearTimeout(exitTimer)
exit()
return
}
// First press — show hint
setPendingExit(true)
toast.warning("Press Ctrl+C again to exit")
exitTimer = setTimeout(() => {
setPendingExit(false)
}, 2000)
return
}
// Ctrl+M — cycle through modes
if (event.name === "m" && event.ctrl) {
if (ext.state.isLoading) {
toast.warning("Cannot switch modes while task is in progress")
return
}
const modes = ext.state.availableModes || []
if (modes.length < 2) return
const currentSlug = ext.state.currentMode
const currentIndex = modes.findIndex((m) => m.slug === currentSlug)
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % modes.length
const nextMode = modes[nextIndex]
if (nextMode) {
ext.sendToExtension({ type: "mode", text: nextMode.slug })
toast.info(`Switched to ${nextMode.label}`)
}
return
}
})
// ================================================================
// Submit handler
// ================================================================
function handleSubmit() {
if (!textareaRef) return
const text = textareaRef.plainText.trim()
if (!text) return
// If there's an active overlay, don't submit — let autocomplete handle Enter
if (activeTrigger() || showHelp()) return
props.onSubmit(text)
textareaRef.clear()
setCurrentText("")
}
// ================================================================
// Content change handler — drives trigger detection
// ================================================================
function handleContentChange() {
if (!textareaRef) return
setCurrentText(textareaRef.plainText)
}
// ================================================================
// Autocomplete title
// ================================================================
const autocompleteTitle = createMemo(() => {
const trigger = activeTrigger()
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 ""
}
})
const autocompleteEmpty = createMemo(() => {
const trigger = activeTrigger()
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"
}
})
const showAutocomplete = createMemo(() => {
const trigger = activeTrigger()
return trigger !== null && trigger.type !== "help"
})
// ================================================================
// Render
// ================================================================
return (
<box flexDirection="column" flexShrink={0}>
{/* Help overlay */}
<HelpOverlay visible={showHelp()} />
{/* Autocomplete overlay */}
<AutocompleteOverlay
visible={showAutocomplete()}
items={autocompleteItems()}
title={autocompleteTitle()}
emptyMessage={autocompleteEmpty()}
onSelect={handleAutocompleteSelect}
onDismiss={handleAutocompleteDismiss}
/>
{/* Prompt input row */}
<box flexDirection="row" flexShrink={0}>
<text fg={props.isActive ? theme.promptColorActive : theme.promptColor} flexShrink={0}>
{props.prefix ?? " "}
</text>
<textarea
ref={(r: TextareaRenderable) => {
textareaRef = r
if (props.ref) {
props.ref({
getText: () => r.plainText,
clear: () => r.clear(),
focus: () => r.focus(),
})
}
}}
placeholder={props.placeholder ?? ""}
textColor={theme.text}
focusedTextColor={theme.text}
placeholderColor={theme.placeholderColor}
minHeight={1}
maxHeight={4}
flexGrow={1}
keyBindings={PROMPT_KEYBINDINGS}
onSubmit={handleSubmit}
onContentChange={handleContentChange}
focused={props.isActive ?? true}
/>
</box>
</box>
)
}

View file

@ -0,0 +1,42 @@
/**
* Tips/hints component showing keyboard shortcuts in a clean grid.
*/
import { useTheme } from "../context/theme.js"
export function Tips() {
const { theme } = useTheme()
return (
<box flexDirection="column" paddingTop={1} paddingLeft={2} paddingRight={2}>
<box flexDirection="row" gap={2}>
<text fg={theme.dimText}>
<span style={{ fg: theme.accent }}>@</span> files
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.accent }}>/</span> commands
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.accent }}>!</span> modes
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.accent }}>#</span> history
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.accent }}>?</span> help
</text>
</box>
<box flexDirection="row" gap={2} paddingTop={0}>
<text fg={theme.dimText}>
<span style={{ fg: theme.textMuted }}>Esc</span> cancel
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.textMuted }}>Ctrl+C</span> exit
</text>
<text fg={theme.dimText}>
<span style={{ fg: theme.textMuted }}>Ctrl+M</span> switch mode
</text>
</box>
</box>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
/**
* Exit provider for graceful app shutdown.
*/
import { useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper.js"
export type ExitFn = ((reason?: unknown) => Promise<void>) & {
message: {
set: (value?: string) => () => void
clear: () => void
get: () => string | undefined
}
}
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
name: "Exit",
init: (input: { onExit?: () => Promise<void> }) => {
const renderer = useRenderer()
let message: string | undefined
const store = {
set: (value?: string) => {
const prev = message
message = value
return () => {
message = prev
}
},
clear: () => {
message = undefined
},
get: () => message,
}
const exit: ExitFn = Object.assign(
async (reason?: unknown) => {
renderer.destroy()
await input.onExit?.()
if (reason) {
const msg = reason instanceof Error ? reason.message : String(reason)
process.stderr.write(msg + "\n")
}
const text = store.get()
if (text) process.stdout.write(text + "\n")
process.exit(0)
},
{
message: store,
},
)
return exit
},
})

View file

@ -0,0 +1,616 @@
/**
* Extension context provider - bridges ExtensionHost to SolidJS store.
*
* This replaces the React hooks: useExtensionHost, useMessageHandlers, useTaskSubmit
* with a single SolidJS context that manages the extension lifecycle and state.
*/
import { createStore, produce } from "solid-js/store"
import { batch, onCleanup, onMount } from "solid-js"
import { randomUUID } from "crypto"
import type {
ClineAsk,
ClineMessage,
ClineSay,
ExtensionMessage,
TodoItem,
TokenUsage,
WebviewMessage,
} from "@roo-code/types"
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli"
import type { ExtensionHostInterface, ExtensionHostOptions } from "../../agent/index.js"
import type {
TUIMessage,
PendingAsk,
ToolData,
FileResult,
SlashCommandResult,
ModeResult,
TaskHistoryItem,
} from "../types.js"
import {
extractToolData,
formatToolOutput,
formatToolAskMessage,
parseTodosFromToolInfo,
} from "../../ui/utils/tools.js"
import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../../lib/utils/commands.js"
import { createSimpleContext } from "./helper.js"
/** Streaming message debounce configuration. */
const STREAMING_DEBOUNCE_MS = 150
export interface ExtensionStore {
messages: TUIMessage[]
pendingAsk: PendingAsk | null
isLoading: boolean
isComplete: boolean
hasStartedTask: boolean
error: string | null
isResumingTask: boolean
// Autocomplete data
fileSearchResults: FileResult[]
allSlashCommands: SlashCommandResult[]
availableModes: ModeResult[]
// Task history
taskHistory: TaskHistoryItem[]
currentTaskId: string | null
// Mode
currentMode: string | null
// Metrics
tokenUsage: TokenUsage | null
// Todos
currentTodos: TodoItem[]
previousTodos: TodoItem[]
}
const initialState: ExtensionStore = {
messages: [],
pendingAsk: null,
isLoading: false,
isComplete: false,
hasStartedTask: false,
error: null,
isResumingTask: false,
fileSearchResults: [],
allSlashCommands: [],
availableModes: [],
taskHistory: [],
currentTaskId: null,
currentMode: null,
tokenUsage: null,
currentTodos: [],
previousTodos: [],
}
export interface ExtensionContextProps {
options: ExtensionHostOptions
initialPrompt?: string
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
onExit?: () => void
}
export const { use: useExtension, provider: ExtensionProvider } = createSimpleContext({
name: "Extension",
init: (props: ExtensionContextProps) => {
const [store, setStore] = createStore<ExtensionStore>({ ...initialState })
let host: ExtensionHostInterface | null = null
const seenMessageIds = new Set<string>()
let firstTextMessageSkipped = false
let pendingCommandRef: string | null = null
// Streaming debounce state
const pendingStreamUpdates = new Map<string, { id: string; content: string; partial: boolean }>()
let streamingDebounceTimer: ReturnType<typeof setTimeout> | null = null
// ================================================================
// Message handling (ported from useMessageHandlers)
// ================================================================
function addMessage(msg: TUIMessage) {
const existingIndex = store.messages.findIndex((m) => m.id === msg.id)
if (existingIndex === -1) {
setStore("messages", (msgs) => [...msgs, msg])
return
}
if (msg.partial) {
pendingStreamUpdates.set(msg.id, {
id: msg.id,
content: msg.content,
partial: true,
})
if (!streamingDebounceTimer) {
streamingDebounceTimer = setTimeout(() => {
const updates = Array.from(pendingStreamUpdates.values())
pendingStreamUpdates.clear()
streamingDebounceTimer = null
if (updates.length === 0) return
setStore(
"messages",
produce((msgs) => {
for (const update of updates) {
const idx = msgs.findIndex((m) => m.id === update.id)
if (idx !== -1 && msgs[idx]) {
msgs[idx]!.content = update.content
msgs[idx]!.partial = update.partial
}
}
}),
)
}, STREAMING_DEBOUNCE_MS)
}
return
}
// Non-partial update
pendingStreamUpdates.delete(msg.id)
setStore(
"messages",
produce((msgs) => {
msgs[existingIndex] = msg
}),
)
}
function handleSayMessage(ts: number, say: ClineSay, text: string, partial: boolean) {
const messageId = ts.toString()
if (say === "checkpoint_saved" || say === "api_req_started" || say === "user_feedback") {
if (say === "user_feedback") seenMessageIds.add(messageId)
return
}
if (say === "text" && !firstTextMessageSkipped && !store.isResumingTask) {
firstTextMessageSkipped = true
seenMessageIds.add(messageId)
return
}
if (seenMessageIds.has(messageId) && !partial) return
let role: TUIMessage["role"] = "assistant"
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: ToolData | undefined
if (say === "command_output") {
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
const trackedCommand = pendingCommandRef
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
pendingCommandRef = null
} else if (say === "reasoning") {
role = "thinking"
}
seenMessageIds.add(messageId)
addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
})
}
function handleAskMessage(ts: number, ask: ClineAsk, text: string, partial: boolean) {
const messageId = ts.toString()
if (partial) return
if (seenMessageIds.has(messageId)) return
if (ask === "command_output") {
seenMessageIds.add(messageId)
return
}
if (ask === "resume_task" || ask === "resume_completed_task") {
seenMessageIds.add(messageId)
batch(() => {
setStore("isLoading", false)
setStore("hasStartedTask", true)
setStore("isResumingTask", false)
})
return
}
if (ask === "completion_result") {
seenMessageIds.add(messageId)
batch(() => {
setStore("isComplete", true)
setStore("isLoading", false)
})
try {
const completionInfo = JSON.parse(text) as Record<string, unknown>
const toolDataVal: ToolData = {
tool: "attempt_completion",
result: completionInfo.result as string | undefined,
content: completionInfo.result as string | undefined,
}
addMessage({
id: messageId,
role: "tool",
content: text,
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
originalType: ask,
toolData: toolDataVal,
})
} catch {
addMessage({
id: messageId,
role: "tool",
content: text || "Task completed",
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: "✅ Task completed",
originalType: ask,
toolData: { tool: "attempt_completion", content: text },
})
}
return
}
if (ask === "command") {
pendingCommandRef = text
}
if (props.options.nonInteractive && ask !== "followup") {
seenMessageIds.add(messageId)
if (ask === "tool") {
let localToolName: string | undefined
let localToolDisplayName: string | undefined
let localToolDisplayOutput: string | undefined
let formattedContent = text || ""
let localToolData: ToolData | undefined
let todos: TodoItem[] | undefined
let previousTodos: TodoItem[] | undefined
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
localToolName = toolInfo.tool as string
localToolDisplayName = toolInfo.tool as string
localToolDisplayOutput = formatToolOutput(toolInfo)
formattedContent = formatToolAskMessage(toolInfo)
localToolData = extractToolData(toolInfo)
if (localToolName === "update_todo_list" || localToolName === "updateTodoList") {
const parsedTodos = parseTodosFromToolInfo(toolInfo)
if (parsedTodos && parsedTodos.length > 0) {
todos = parsedTodos
previousTodos = [...store.currentTodos]
setStore("previousTodos", store.currentTodos)
setStore("currentTodos", parsedTodos)
}
}
} catch {
// Use raw text
}
addMessage({
id: messageId,
role: "tool",
content: formattedContent,
toolName: localToolName,
toolDisplayName: localToolDisplayName,
toolDisplayOutput: localToolDisplayOutput,
originalType: ask,
toolData: localToolData,
todos,
previousTodos,
})
} else {
addMessage({
id: messageId,
role: "assistant",
content: text || "",
originalType: ask,
})
}
return
}
let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
let questionText = text
if (ask === "followup") {
try {
const data = JSON.parse(text)
questionText = data.question || text
suggestions = Array.isArray(data.suggest) ? data.suggest : undefined
} catch {
// Use raw text
}
} else if (ask === "tool") {
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
questionText = formatToolAskMessage(toolInfo)
} catch {
// Use raw text
}
}
seenMessageIds.add(messageId)
setStore("pendingAsk", {
id: messageId,
type: ask,
content: questionText,
suggestions,
})
}
function handleExtensionMessage(msg: ExtensionMessage) {
if (msg.type === "state") {
const state = msg.state
if (!state) return
batch(() => {
const newMode = state.mode
if (newMode) setStore("currentMode", newMode)
const newTaskHistory = state.taskHistory
if (newTaskHistory && Array.isArray(newTaskHistory)) {
setStore("taskHistory", newTaskHistory as TaskHistoryItem[])
}
const clineMessages = state.clineMessages
if (clineMessages) {
for (const clineMsg of clineMessages) {
const { ts, type, say, ask, text = "", partial = false } = clineMsg
if (type === "say" && say) handleSayMessage(ts, say, text, partial as boolean)
else if (type === "ask" && ask) handleAskMessage(ts, ask, text, partial as boolean)
}
if (clineMessages.length > 1) {
const processed = consolidateApiRequests(
consolidateCommands(clineMessages.slice(1) as ClineMessage[]),
)
const metrics = consolidateTokenUsage(processed)
setStore("tokenUsage", metrics)
}
}
if (store.isResumingTask) {
setStore("isResumingTask", false)
}
})
} else if (msg.type === "messageUpdated") {
const clineMessage = msg.clineMessage
if (!clineMessage) return
const { ts, type, say, ask, text = "", partial = false } = clineMessage
if (type === "say" && say) handleSayMessage(ts, say, text, partial as boolean)
else if (type === "ask" && ask) handleAskMessage(ts, ask, text, partial as boolean)
} else if (msg.type === "fileSearchResults") {
setStore("fileSearchResults", (msg.results as FileResult[]) || [])
} else if (msg.type === "commands") {
setStore("allSlashCommands", (msg.commands as SlashCommandResult[]) || [])
} else if (msg.type === "modes") {
setStore("availableModes", (msg.modes as ModeResult[]) || [])
} else if (msg.type === "routerModels") {
// We can add routerModels to the store if needed
}
}
// ================================================================
// Task actions (ported from useTaskSubmit)
// ================================================================
function sendToExtension(msg: WebviewMessage) {
host?.sendToExtension(msg)
}
async function runTask(prompt: string) {
if (!host) throw new Error("Extension host not ready")
return host.runTask(prompt)
}
async function handleSubmit(text: string) {
if (!host || !text.trim()) return
const trimmedText = text.trim()
if (trimmedText === "__CUSTOM__") return
// Check for CLI global action commands
if (trimmedText.startsWith("/")) {
const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/)
if (commandMatch && commandMatch[1]) {
const globalCommand = getGlobalCommand(commandMatch[1])
if (globalCommand?.action === "clearTask") {
// Reset state
batch(() => {
setStore("messages", [])
setStore("pendingAsk", null)
setStore("isLoading", false)
setStore("isComplete", false)
setStore("hasStartedTask", false)
setStore("error", null)
setStore("isResumingTask", false)
setStore("tokenUsage", null)
setStore("currentTodos", [])
setStore("previousTodos", [])
})
seenMessageIds.clear()
firstTextMessageSkipped = false
sendToExtension({ type: "clearTask" })
sendToExtension({ type: "requestCommands" })
sendToExtension({ type: "requestModes" })
return
}
}
}
if (store.pendingAsk) {
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
batch(() => {
setStore("pendingAsk", null)
setStore("isLoading", true)
})
} else if (!store.hasStartedTask) {
batch(() => {
setStore("hasStartedTask", true)
setStore("isLoading", true)
})
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
try {
await runTask(trimmedText)
} catch (err) {
batch(() => {
setStore("error", err instanceof Error ? err.message : String(err))
setStore("isLoading", false)
})
}
} else {
if (store.isComplete) setStore("isComplete", false)
setStore("isLoading", true)
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
}
}
function handleApprove() {
if (!host) return
sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" })
batch(() => {
setStore("pendingAsk", null)
setStore("isLoading", true)
})
}
function handleReject() {
if (!host) return
sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" })
batch(() => {
setStore("pendingAsk", null)
setStore("isLoading", true)
})
}
// ================================================================
// Lifecycle
// ================================================================
onMount(async () => {
try {
host = props.createExtensionHost({
...props.options,
disableOutput: true,
})
host.on("extensionWebviewMessage", (msg) => {
handleExtensionMessage(msg as ExtensionMessage)
})
host.client.on("taskCompleted", async () => {
batch(() => {
setStore("isComplete", true)
setStore("isLoading", false)
})
if (props.options.exitOnComplete) {
await host?.dispose()
props.onExit?.()
setTimeout(() => process.exit(0), 100)
}
})
host.client.on("error", (err: Error) => {
batch(() => {
setStore("error", err.message)
setStore("isLoading", false)
})
})
await host.activate()
sendToExtension({ type: "requestCommands" })
sendToExtension({ type: "requestModes" })
setStore("isLoading", false)
if (props.initialPrompt) {
batch(() => {
setStore("hasStartedTask", true)
setStore("isLoading", true)
})
addMessage({ id: randomUUID(), role: "user", content: props.initialPrompt })
await host.runTask(props.initialPrompt)
}
} catch (err) {
batch(() => {
setStore("error", err instanceof Error ? err.message : String(err))
setStore("isLoading", false)
})
}
})
onCleanup(async () => {
if (streamingDebounceTimer) clearTimeout(streamingDebounceTimer)
if (host) {
await host.dispose()
host = null
}
})
return {
get state() {
return store
},
sendToExtension,
runTask,
handleSubmit,
handleApprove,
handleReject,
searchFiles(query: string) {
sendToExtension({ type: "searchFiles", query })
},
cancelTask() {
sendToExtension({ type: "cancelTask" })
},
resumeTask(taskId: string) {
batch(() => {
setStore("isResumingTask", true)
setStore("hasStartedTask", true)
setStore("isLoading", true)
setStore("isComplete", false)
})
sendToExtension({ type: "showTaskWithId", text: taskId })
},
}
},
})

View file

@ -0,0 +1,30 @@
/**
* Context helper utility for creating SolidJS context providers.
* Adapted from OpenCode's createSimpleContext pattern.
*/
import { createContext, Show, useContext, type ParentProps } from "solid-js"
export function createSimpleContext<T, Props extends Record<string, unknown>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
return (
<Show
when={!(init as Record<string, unknown>).ready || (init as Record<string, unknown>).ready === true}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

View file

@ -0,0 +1,84 @@
/**
* Keybind provider for leader key system (Ctrl+X prefix).
* Adapted from OpenCode's keybind context.
*/
import { createSignal, onCleanup } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { KeyEvent } from "@opentui/core"
import { createSimpleContext } from "./helper.js"
export interface Keybinding {
key: string
label: string
action: () => void
/** If true, requires Ctrl+X prefix */
leader?: boolean
}
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const [leaderActive, setLeaderActive] = createSignal(false)
const bindings = new Map<string, Keybinding>()
let leaderTimer: ReturnType<typeof setTimeout> | undefined
function clearLeader() {
setLeaderActive(false)
if (leaderTimer) {
clearTimeout(leaderTimer)
leaderTimer = undefined
}
}
function activateLeader() {
setLeaderActive(true)
// Auto-clear leader after 2 seconds
leaderTimer = setTimeout(clearLeader, 2000)
}
useKeyboard((event: KeyEvent) => {
// Ctrl+X activates leader mode
if (event.name === "x" && event.ctrl) {
if (!leaderActive()) {
activateLeader()
return
}
}
if (leaderActive()) {
clearLeader()
// Look up leader + key binding
const binding = bindings.get(`leader+${event.name}`)
if (binding) {
binding.action()
return
}
}
// Direct keybindings (no leader prefix)
const binding = bindings.get(event.name)
if (binding && !binding.leader) {
binding.action()
}
})
onCleanup(clearLeader)
return {
get isLeaderActive() {
return leaderActive()
},
register(key: string, binding: Omit<Keybinding, "key">) {
const fullKey = binding.leader ? `leader+${key}` : key
bindings.set(fullKey, { ...binding, key })
return () => {
bindings.delete(fullKey)
}
},
getBindings() {
return Array.from(bindings.values())
},
}
},
})

View file

@ -0,0 +1,25 @@
/**
* Route provider for navigation between home and session views.
*/
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper.js"
import type { Route } from "../types.js"
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: () => {
const [store, setStore] = createStore<Route>({ type: "home" })
return {
get data() {
return store
},
navigate(route: Route) {
setStore(route)
},
}
},
})
export type RouteContext = ReturnType<typeof useRoute>

View file

@ -0,0 +1,122 @@
/**
* Theme provider for the SolidJS/opentui TUI.
* Provides a hardcoded "hardcore" theme matching the current CLI look.
* Can be extended to support JSON theme files from OpenCode.
*/
import { RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper.js"
export interface ThemeColors {
primary: RGBA
secondary: RGBA
accent: RGBA
error: RGBA
warning: RGBA
success: RGBA
info: RGBA
text: RGBA
textMuted: RGBA
background: RGBA
backgroundPanel: RGBA
backgroundElement: RGBA
border: RGBA
borderActive: RGBA
borderSubtle: RGBA
diffAdded: RGBA
diffRemoved: RGBA
syntaxKeyword: RGBA
syntaxFunction: RGBA
syntaxString: RGBA
syntaxComment: RGBA
// Roo-specific semantic colors
userHeader: RGBA
rooHeader: RGBA
toolHeader: RGBA
thinkingHeader: RGBA
userText: RGBA
rooText: RGBA
toolText: RGBA
thinkingText: RGBA
promptColor: RGBA
promptColorActive: RGBA
placeholderColor: RGBA
dimText: RGBA
scrollActiveColor: RGBA
scrollTrackColor: RGBA
titleColor: RGBA
asciiColor: RGBA
tipsHeader: RGBA
tipsText: RGBA
}
/** The Hardcore palette - matching current theme.ts */
const hardcore: ThemeColors = {
primary: RGBA.fromHex("#FD971F"),
secondary: RGBA.fromHex("#9E6FFE"),
accent: RGBA.fromHex("#66D9EF"),
error: RGBA.fromHex("#F92672"),
warning: RGBA.fromHex("#E6DB74"),
success: RGBA.fromHex("#A6E22E"),
info: RGBA.fromHex("#66D9EF"),
text: RGBA.fromHex("#F8F8F2"),
textMuted: RGBA.fromHex("#A3BABF"),
background: RGBA.fromHex("#1B1D1E"),
backgroundPanel: RGBA.fromHex("#2d2e2e"),
backgroundElement: RGBA.fromHex("#383a3e"),
border: RGBA.fromHex("#383a3e"),
borderActive: RGBA.fromHex("#9E6FFE"),
borderSubtle: RGBA.fromHex("#505354"),
diffAdded: RGBA.fromHex("#A6E22E"),
diffRemoved: RGBA.fromHex("#F92672"),
syntaxKeyword: RGBA.fromHex("#F92672"),
syntaxFunction: RGBA.fromHex("#A6E22E"),
syntaxString: RGBA.fromHex("#E6DB74"),
syntaxComment: RGBA.fromHex("#5E7175"),
// Roo-specific
userHeader: RGBA.fromHex("#9E6FFE"),
rooHeader: RGBA.fromHex("#E6DB74"),
toolHeader: RGBA.fromHex("#66D9EF"),
thinkingHeader: RGBA.fromHex("#5E7175"),
userText: RGBA.fromHex("#F8F8F2"),
rooText: RGBA.fromHex("#F8F8F2"),
toolText: RGBA.fromHex("#A3BABF"),
thinkingText: RGBA.fromHex("#A3BABF"),
promptColor: RGBA.fromHex("#A3BABF"),
promptColorActive: RGBA.fromHex("#66D9EF"),
placeholderColor: RGBA.fromHex("#505354"),
dimText: RGBA.fromHex("#5E7175"),
scrollActiveColor: RGBA.fromHex("#9E6FFE"),
scrollTrackColor: RGBA.fromHex("#383a3e"),
titleColor: RGBA.fromHex("#FD971F"),
asciiColor: RGBA.fromHex("#66D9EF"),
tipsHeader: RGBA.fromHex("#FD971F"),
tipsText: RGBA.fromHex("#A3BABF"),
}
export const THEMES: Record<string, ThemeColors> = {
hardcore,
}
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: () => {
const [themeName, setThemeName] = createStore({ name: "hardcore" })
return {
get theme() {
return THEMES[themeName.name] ?? hardcore
},
get name() {
return themeName.name
},
setTheme(name: string) {
if (THEMES[name]) {
setThemeName("name", name)
}
},
themes: Object.keys(THEMES),
}
},
})

View file

@ -0,0 +1,47 @@
/**
* Toast provider for ephemeral notifications.
*/
import { createSignal, onCleanup } from "solid-js"
import { createSimpleContext } from "./helper.js"
export interface Toast {
id: number
message: string
variant: "info" | "success" | "warning" | "error"
duration: number
}
export const { use: useToast, provider: ToastProvider } = createSimpleContext({
name: "Toast",
init: () => {
const [current, setCurrent] = createSignal<Toast | null>(null)
let nextId = 0
let timer: ReturnType<typeof setTimeout> | undefined
function show(message: string, variant: Toast["variant"] = "info", duration = 3000) {
if (timer) clearTimeout(timer)
const id = nextId++
setCurrent({ id, message, variant, duration })
timer = setTimeout(() => {
setCurrent((prev) => (prev?.id === id ? null : prev))
}, duration)
}
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return {
get current() {
return current()
},
show,
info: (msg: string) => show(msg, "info"),
success: (msg: string) => show(msg, "success"),
warning: (msg: string) => show(msg, "warning"),
error: (msg: string) => show(msg, "error"),
clear: () => setCurrent(null),
}
},
})

View file

@ -0,0 +1,31 @@
/**
* Entry point for the SolidJS/opentui TUI.
*
* This file initializes the opentui renderer and mounts the SolidJS app.
* It is built separately from the main CLI entry using:
* bun build src/ui-next/main.tsx --outdir=dist/ui-next --target=bun --external "@vscode/ripgrep"
*
* Run with:
* ROO_CLI_ROOT=$(pwd) bun dist/ui-next/main.js
*/
import { render } from "@opentui/solid"
import { App, type TUIAppProps } from "./app.js"
/**
* Start the SolidJS/opentui TUI.
* Called from the CLI run command when TUI mode is enabled.
*/
export async function startTUI(props: TUIAppProps): Promise<void> {
await render(() => <App {...props} />)
}
// When run directly (standalone mode), parse minimal args and start
if (import.meta.main || process.argv[1]?.includes("ui-next")) {
// This path is for the standalone build:
// ROO_CLI_ROOT=$(pwd) bun dist/ui-next/main.js
console.log("Roo Code CLI TUI (SolidJS/opentui)")
console.log("This module should be imported and called via startTUI()")
console.log("Use the main CLI: roo <prompt>")
process.exit(0)
}

View file

@ -0,0 +1,45 @@
/**
* Home route - welcome screen with kangaroo logo and prompt.
*/
import { useTheme } from "../context/theme.js"
import { useRoute } from "../context/route.js"
import { useExtension } from "../context/extension.js"
import { Logo } from "../component/logo.js"
import { Tips } from "../component/tips.js"
import { Prompt } from "../component/prompt/index.js"
export function Home() {
const { theme } = useTheme()
const route = useRoute()
const ext = useExtension()
function handleSubmit(text: string) {
route.navigate({ type: "session", initialPrompt: text })
ext.handleSubmit(text)
}
return (
<box flexGrow={1} flexDirection="column" justifyContent="center" alignItems="center">
<Logo />
<box height={1} />
<box
width="100%"
maxWidth={72}
flexDirection="column"
borderStyle="rounded"
borderColor={theme.borderActive}
paddingLeft={1}
paddingRight={1}>
<Prompt
placeholder="What would you like to do?"
onSubmit={handleSubmit}
isActive={true}
prefix=" "
enableTriggers={true}
/>
</box>
<Tips />
</box>
)
}

View file

@ -0,0 +1,61 @@
/**
* Session footer - shows status messages, loading state, input hints.
*/
import { Show, createMemo, createSignal, onCleanup } from "solid-js"
import { useTheme } from "../../context/theme.js"
import { useExtension } from "../../context/extension.js"
import { useToast } from "../../context/toast.js"
import { getSpinnerFrame } from "../../ui/spinner.js"
export function SessionFooter() {
const { theme } = useTheme()
const ext = useExtension()
const toast = useToast()
// Spinner tick for loading animation
const [tick, setTick] = createSignal(0)
const spinnerTimer = setInterval(() => setTick((t) => t + 1), 80)
onCleanup(() => clearInterval(spinnerTimer))
const isLoading = createMemo(() => ext.state.isLoading && !ext.state.pendingAsk)
const statusText = createMemo(() => {
if (toast.current) {
return null // Toast takes over
}
if (isLoading()) {
const frame = getSpinnerFrame(tick())
return `${frame} Thinking... • Esc to cancel`
}
return "? for shortcuts"
})
return (
<box height={1} flexShrink={0} paddingLeft={1}>
<Show
when={toast.current}
fallback={
<Show when={statusText()}>
{(text) => <text fg={isLoading() ? theme.accent : theme.dimText}>{text()}</text>}
</Show>
}>
{(t) => {
const color = () => {
switch (t().variant) {
case "success":
return theme.success
case "error":
return theme.error
case "warning":
return theme.warning
default:
return theme.info
}
}
return <text fg={color()}>{t().message}</text>
}}
</Show>
</box>
)
}

View file

@ -0,0 +1,50 @@
/**
* Session header component - displays mode, model, and metrics.
*/
import { Show, createMemo } from "solid-js"
import { useTheme } from "../../context/theme.js"
import { useExtension } from "../../context/extension.js"
export interface SessionHeaderProps {
version: string
mode: string
provider: string
model: string
}
export function SessionHeader(props: SessionHeaderProps) {
const { theme } = useTheme()
const ext = useExtension()
const displayMode = createMemo(() => ext.state.currentMode || props.mode)
const tokenDisplay = createMemo(() => {
const usage = ext.state.tokenUsage
if (!usage) return null
const { totalTokensIn = 0, totalTokensOut = 0, totalCost } = usage
const inK = (totalTokensIn / 1000).toFixed(1)
const outK = (totalTokensOut / 1000).toFixed(1)
let text = `${inK}k↑ ${outK}k↓`
if (totalCost !== undefined && totalCost > 0) {
text += ` $${totalCost.toFixed(4)}`
}
return text
})
return (
<box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
<box flexGrow={1} flexDirection="row" gap={1}>
<text fg={theme.titleColor} bold>
Roo
</text>
<text fg={theme.dimText}>v{props.version}</text>
<text fg={theme.dimText}></text>
<text fg={theme.accent}>{displayMode()}</text>
<text fg={theme.dimText}></text>
<text fg={theme.textMuted}>{props.model}</text>
</box>
<Show when={tokenDisplay()}>{(tokens) => <text fg={theme.dimText}>{tokens()}</text>}</Show>
</box>
)
}

View file

@ -0,0 +1,242 @@
/**
* Session view - main conversation view with message history,
* approval prompts, and text input.
*/
import { For, Show, Switch, Match, createMemo, createSignal } from "solid-js"
import { useTerminalDimensions, useKeyboard } from "@opentui/solid"
import { type KeyEvent } from "@opentui/core"
import { useTheme } from "../../context/theme.js"
import { useExtension } from "../../context/extension.js"
import { HorizontalLine } from "../../component/border.js"
import { Prompt } from "../../component/prompt/index.js"
import { SessionHeader } from "./header.js"
import { SessionFooter } from "./footer.js"
import type { TUIMessage } from "../../types.js"
export interface SessionProps {
version: string
mode: string
provider: string
model: string
}
/** Render a single chat message */
function ChatMessage(props: { message: TUIMessage }) {
const { theme } = useTheme()
const headerColor = () => {
switch (props.message.role) {
case "user":
return theme.userHeader
case "assistant":
return theme.rooHeader
case "tool":
return theme.toolHeader
case "thinking":
return theme.thinkingHeader
default:
return theme.text
}
}
const textColor = () => {
switch (props.message.role) {
case "user":
return theme.userText
case "assistant":
return theme.rooText
case "tool":
return theme.toolText
case "thinking":
return theme.thinkingText
default:
return theme.text
}
}
const headerLabel = () => {
switch (props.message.role) {
case "user":
return "You"
case "assistant":
return "Roo"
case "tool":
return props.message.toolDisplayName || "Tool"
case "thinking":
return "Thinking"
default:
return ""
}
}
const displayContent = () => {
if (props.message.role === "tool" && props.message.toolDisplayOutput) {
return props.message.toolDisplayOutput
}
return props.message.content
}
return (
<box flexDirection="column" paddingLeft={1} paddingBottom={1}>
<text fg={headerColor()} bold>
{headerLabel()}
{props.message.partial ? " ..." : ""}
</text>
<Show when={displayContent()}>
<text fg={textColor()} wrap="wrap">
{displayContent()}
</text>
</Show>
</box>
)
}
/** Approval prompt for tool use / command approvals */
function ApprovalPrompt(props: { content: string }) {
const { theme } = useTheme()
const ext = useExtension()
useKeyboard((event: KeyEvent) => {
const lower = event.name?.toLowerCase()
if (lower === "y") ext.handleApprove()
else if (lower === "n") ext.handleReject()
})
return (
<box flexDirection="column">
<text fg={theme.rooHeader}>{props.content}</text>
<box flexDirection="row" gap={1}>
<text fg={theme.dimText}>Press</text>
<text fg={theme.success}>Y</text>
<text fg={theme.dimText}>to approve,</text>
<text fg={theme.error}>N</text>
<text fg={theme.dimText}>to reject</text>
</box>
</box>
)
}
/** Followup question prompt */
function FollowupPrompt(props: { content: string; suggestions?: Array<{ answer: string }> }) {
const { theme } = useTheme()
const ext = useExtension()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const hasSuggestions = () => props.suggestions && props.suggestions.length > 0
useKeyboard((event: KeyEvent) => {
if (!hasSuggestions()) return
if (event.name === "up") {
setSelectedIndex((i) => Math.max(0, i - 1))
} else if (event.name === "down") {
setSelectedIndex((i) => Math.min(props.suggestions?.length ?? 0, i + 1))
} else if (event.name === "return") {
const suggestions = props.suggestions || []
const idx = selectedIndex()
if (idx < suggestions.length && suggestions[idx]) {
ext.handleSubmit(suggestions[idx].answer)
}
}
})
return (
<box flexDirection="column">
<text fg={theme.rooHeader}>{props.content}</text>
<Show
when={hasSuggestions()}
fallback={
<box flexDirection="column" marginTop={1}>
<HorizontalLine active={true} />
<Prompt
placeholder="Type your response..."
onSubmit={(text) => ext.handleSubmit(text)}
isActive={true}
prefix="> "
enableTriggers={true}
/>
<HorizontalLine active={true} />
</box>
}>
<box flexDirection="column" marginTop={1}>
<HorizontalLine active={true} />
<For each={props.suggestions}>
{(suggestion, index) => (
<text fg={index() === selectedIndex() ? theme.accent : theme.text}>
{index() === selectedIndex() ? " " : " "}
{suggestion.answer}
</text>
)}
</For>
<text fg={selectedIndex() === (props.suggestions?.length ?? 0) ? theme.accent : theme.dimText}>
{selectedIndex() === (props.suggestions?.length ?? 0) ? " " : " "}Type something...
</text>
<HorizontalLine active={true} />
<text fg={theme.dimText}> navigate Enter select</text>
</box>
</Show>
</box>
)
}
export function Session(props: SessionProps) {
const { theme } = useTheme()
const ext = useExtension()
const dims = useTerminalDimensions()
const messages = () => ext.state.messages
const pendingAsk = () => ext.state.pendingAsk
const isComplete = () => ext.state.isComplete
const showApprovalPrompt = () => pendingAsk() && pendingAsk()?.type !== "followup"
return (
<box flexDirection="column" height={dims().height - 1}>
{/* Header */}
<SessionHeader version={props.version} mode={props.mode} provider={props.provider} model={props.model} />
{/* Message history - scrollable area */}
<scrollbox flexGrow={1} scrollbar="auto">
<box flexDirection="column">
<For each={messages()}>{(message) => <ChatMessage message={message} />}</For>
</box>
</scrollbox>
{/* Input area */}
<box flexDirection="column" flexShrink={0}>
<Switch>
{/* Followup question */}
<Match when={pendingAsk()?.type === "followup"}>
<FollowupPrompt
content={pendingAsk()!.content}
suggestions={pendingAsk()?.suggestions as Array<{ answer: string }> | undefined}
/>
</Match>
{/* Approval prompt (Y/N) */}
<Match when={showApprovalPrompt()}>
<ApprovalPrompt content={pendingAsk()!.content} />
</Match>
{/* Normal text input */}
<Match when={true}>
<box flexDirection="column">
<HorizontalLine active={true} />
<Prompt
placeholder={isComplete() ? "Type to continue..." : ""}
onSubmit={(text) => ext.handleSubmit(text)}
isActive={true}
prefix=" "
enableTriggers={true}
/>
<HorizontalLine active={true} />
</box>
</Match>
</Switch>
</box>
{/* Footer */}
<SessionFooter />
</box>
)
}

View file

@ -0,0 +1,121 @@
/**
* Types for the SolidJS/opentui TUI layer.
* Mirrors the existing ui/types.ts but adapted for the new architecture.
*/
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
export interface ToolData {
/** Tool identifier (e.g., "readFile", "appliedDiff", "searchFiles") */
tool: string
// File operation fields
path?: string
isOutsideWorkspace?: boolean
isProtected?: boolean
diff?: string
diffStats?: { added: number; removed: number }
content?: string
// Search operation fields
regex?: string
filePattern?: string
query?: string
// Mode operation fields
mode?: string
reason?: string
// Command operation fields
command?: string
output?: string
// Browser operation fields
action?: string
url?: string
coordinate?: string
// Batch operation fields
batchFiles?: Array<{
path: string
lineSnippet?: string
isOutsideWorkspace?: boolean
key?: string
content?: string
}>
batchDiffs?: Array<{
path: string
changeCount?: number
key?: string
content?: string
diffStats?: { added: number; removed: number }
diffs?: Array<{
content: string
startLine?: number
}>
}>
// Question/completion fields
question?: string
result?: string
// Additional display hints
lineNumber?: number
additionalFileCount?: number
}
export interface TUIMessage {
id: string
role: MessageRole
content: string
toolName?: string
toolDisplayName?: string
toolDisplayOutput?: string
hasPendingToolCalls?: boolean
partial?: boolean
originalType?: ClineAsk | ClineSay
todos?: TodoItem[]
previousTodos?: TodoItem[]
toolData?: ToolData
}
export interface PendingAsk {
id: string
type: ClineAsk
content: string
suggestions?: Array<{ answer: string; mode?: string | null }>
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
export interface TaskHistoryItem {
id: string
task: string
ts: number
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
tokensIn?: number
tokensOut?: number
}
export type FileResult = { key: string; label: string; path: string }
export type SlashCommandResult = { key: string; label: string; description?: string }
export type ModeResult = { key: string; label: string; slug: string }
export type HistoryResult = { key: string; label: string; id: string; task: string; ts: number }
/** Route types for navigation */
export type HomeRoute = {
type: "home"
initialPrompt?: string
}
export type SessionRoute = {
type: "session"
initialPrompt?: string
}
export type Route = HomeRoute | SessionRoute

View file

@ -0,0 +1,44 @@
/**
* Tests for spinner animation utilities.
*/
import { getSpinnerFrame, SPINNER_FRAMES } from "../spinner.js"
describe("getSpinnerFrame", () => {
it("returns the first frame for tick 0", () => {
expect(getSpinnerFrame(0)).toBe(SPINNER_FRAMES[0])
})
it("returns the correct frame for valid tick", () => {
expect(getSpinnerFrame(3)).toBe(SPINNER_FRAMES[3])
})
it("wraps around when tick exceeds frame count", () => {
const frameCount = SPINNER_FRAMES.length
expect(getSpinnerFrame(frameCount)).toBe(SPINNER_FRAMES[0])
expect(getSpinnerFrame(frameCount + 1)).toBe(SPINNER_FRAMES[1])
expect(getSpinnerFrame(frameCount + 5)).toBe(SPINNER_FRAMES[5])
})
it("handles large tick numbers", () => {
const largeTick = 1234567
const expectedIndex = largeTick % SPINNER_FRAMES.length
expect(getSpinnerFrame(largeTick)).toBe(SPINNER_FRAMES[expectedIndex])
})
it("always returns a non-empty string", () => {
for (let i = 0; i < 100; i++) {
const frame = getSpinnerFrame(i)
expect(frame).toBeTruthy()
expect(typeof frame).toBe("string")
}
})
it("cycles through all frames correctly", () => {
const frames: string[] = []
for (let i = 0; i < SPINNER_FRAMES.length; i++) {
frames.push(getSpinnerFrame(i))
}
expect(frames).toEqual(SPINNER_FRAMES)
})
})

View file

@ -0,0 +1,55 @@
/**
* Dialog system for modal overlays.
*/
import { createSignal, Show, type ParentProps } from "solid-js"
import { useTheme } from "../context/theme.js"
import { createSimpleContext } from "../context/helper.js"
export interface DialogState {
isOpen: boolean
title?: string
}
export const { use: useDialog, provider: DialogProvider } = createSimpleContext({
name: "Dialog",
init: () => {
const [state, setState] = createSignal<DialogState>({ isOpen: false })
return {
get isOpen() {
return state().isOpen
},
get title() {
return state().title
},
open(title?: string) {
setState({ isOpen: true, title })
},
close() {
setState({ isOpen: false })
},
clear() {
setState({ isOpen: false })
},
}
},
})
/** A simple dialog overlay component */
export function DialogOverlay(props: ParentProps<{ title?: string; visible: boolean }>) {
const { theme } = useTheme()
return (
<Show when={props.visible}>
<box flexDirection="column" borderStyle="rounded" borderColor={theme.borderActive} padding={1}>
<Show when={props.title}>
<text bold fg={theme.primary}>
{props.title}
</text>
</Show>
{props.children}
</box>
</Show>
)
}

View file

@ -0,0 +1,9 @@
/**
* Spinner animation frames for loading indicators.
*/
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function getSpinnerFrame(tick: number): string {
return SPINNER_FRAMES[tick % SPINNER_FRAMES.length]!
}

View file

@ -0,0 +1,33 @@
/**
* Toast display component.
*/
import { Show } from "solid-js"
import { useToast } from "../context/toast.js"
import { useTheme } from "../context/theme.js"
export function ToastDisplay() {
const toast = useToast()
const { theme } = useTheme()
return (
<Show when={toast.current}>
{(t) => {
const color = () => {
switch (t().variant) {
case "success":
return theme.success
case "error":
return theme.error
case "warning":
return theme.warning
default:
return theme.info
}
}
return <text fg={color()}>{t().message}</text>
}}
</Show>
)
}

View file

@ -0,0 +1,105 @@
/**
* Tests for clipboard utilities using OSC 52 escape sequences.
*/
import { copyToClipboard, clearClipboard } from "../clipboard.js"
describe("copyToClipboard", () => {
let writeSpy: ReturnType<typeof vi.fn>
let originalWrite: typeof process.stdout.write
beforeEach(() => {
originalWrite = process.stdout.write
writeSpy = vi.fn()
process.stdout.write = writeSpy as any
})
afterEach(() => {
process.stdout.write = originalWrite
})
it("writes OSC 52 escape sequence for simple text", () => {
copyToClipboard("hello")
expect(writeSpy).toHaveBeenCalledTimes(1)
const output = writeSpy.mock.calls[0]![0] as string
expect(output).toMatch(/^\x1b\]52;c;.*\x07$/)
// Verify base64 encoding
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
expect(base64Match).toBeTruthy()
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe("hello")
})
it("handles empty string", () => {
copyToClipboard("")
expect(writeSpy).toHaveBeenCalledTimes(1)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe("")
})
it("handles multiline text", () => {
const text = "line 1\nline 2\nline 3"
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
it("handles unicode characters", () => {
const text = "Hello 世界 🚀"
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
it("handles special characters", () => {
const text = "\t\r\n\0"
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
it("handles large text", () => {
const largeText = "a".repeat(10000)
copyToClipboard(largeText)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(largeText)
})
})
describe("clearClipboard", () => {
let writeSpy: ReturnType<typeof vi.fn>
let originalWrite: typeof process.stdout.write
beforeEach(() => {
originalWrite = process.stdout.write
writeSpy = vi.fn()
process.stdout.write = writeSpy as any
})
afterEach(() => {
process.stdout.write = originalWrite
})
it("writes OSC 52 clear sequence", () => {
clearClipboard()
expect(writeSpy).toHaveBeenCalledTimes(1)
expect(writeSpy).toHaveBeenCalledWith("\x1b]52;c;!\x07")
})
})

View file

@ -0,0 +1,132 @@
/**
* Tests for external editor support.
*/
import { execSync } from "child_process"
import { writeFileSync, readFileSync, unlinkSync } from "fs"
import { openEditor } from "../editor.js"
vi.mock("child_process")
vi.mock("fs")
describe("openEditor", () => {
const mockExecSync = execSync as unknown as ReturnType<typeof vi.fn>
const mockWriteFileSync = writeFileSync as unknown as ReturnType<typeof vi.fn>
const mockReadFileSync = readFileSync as unknown as ReturnType<typeof vi.fn>
const mockUnlinkSync = unlinkSync as unknown as ReturnType<typeof vi.fn>
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
originalEnv = { ...process.env }
vi.clearAllMocks()
mockReadFileSync.mockReturnValue("edited content")
})
afterEach(() => {
process.env = originalEnv
})
it("opens $EDITOR with initial content", () => {
process.env.EDITOR = "nano"
const result = openEditor("initial text")
expect(mockWriteFileSync).toHaveBeenCalledWith(
expect.stringMatching(/roo-edit-\d+\.md$/),
"initial text",
"utf-8",
)
expect(mockExecSync).toHaveBeenCalledWith(expect.stringMatching(/^nano .*roo-edit-\d+\.md$/), {
stdio: "inherit",
})
expect(result).toBe("edited content")
})
it("opens $VISUAL if $EDITOR not set", () => {
delete process.env.EDITOR
process.env.VISUAL = "vim"
openEditor()
expect(mockExecSync).toHaveBeenCalledWith(expect.stringMatching(/^vim .*roo-edit-\d+\.md$/), {
stdio: "inherit",
})
})
it("defaults to 'vi' if no editor is set", () => {
delete process.env.EDITOR
delete process.env.VISUAL
openEditor()
expect(mockExecSync).toHaveBeenCalledWith(expect.stringMatching(/^vi .*roo-edit-\d+\.md$/), {
stdio: "inherit",
})
})
it("uses empty string as initial content if not provided", () => {
process.env.EDITOR = "nano"
openEditor()
expect(mockWriteFileSync).toHaveBeenCalledWith(expect.any(String), "", "utf-8")
})
it("returns edited content from file", () => {
process.env.EDITOR = "nano"
mockReadFileSync.mockReturnValue("new edited text")
const result = openEditor()
expect(result).toBe("new edited text")
})
it("cleans up temporary file after success", () => {
process.env.EDITOR = "nano"
openEditor()
expect(mockUnlinkSync).toHaveBeenCalledWith(expect.stringMatching(/roo-edit-\d+\.md$/))
})
it("returns null if editor execution fails", () => {
process.env.EDITOR = "nano"
mockExecSync.mockImplementation(() => {
throw new Error("Editor failed")
})
const result = openEditor()
expect(result).toBeNull()
})
it("still attempts cleanup if editor fails", () => {
process.env.EDITOR = "nano"
mockExecSync.mockImplementation(() => {
throw new Error("Editor failed")
})
openEditor()
expect(mockUnlinkSync).toHaveBeenCalled()
})
it("handles cleanup errors gracefully", () => {
process.env.EDITOR = "nano"
mockUnlinkSync.mockImplementation(() => {
throw new Error("Cannot delete file")
})
// Should not throw
expect(() => openEditor()).not.toThrow()
})
it("creates temp file with timestamp in name", () => {
process.env.EDITOR = "nano"
const beforeTime = Date.now()
openEditor()
const afterTime = Date.now()
const writeCall = mockWriteFileSync.mock.calls[0]
const filePath = writeCall![0] as string
const timestampMatch = filePath.match(/roo-edit-(\d+)\.md$/)
expect(timestampMatch).toBeTruthy()
const timestamp = parseInt(timestampMatch![1]!, 10)
expect(timestamp).toBeGreaterThanOrEqual(beforeTime)
expect(timestamp).toBeLessThanOrEqual(afterTime)
})
})

View file

@ -0,0 +1,180 @@
/**
* Tests for terminal utility functions.
*/
import { getTerminalBackgroundColor } from "../terminal.js"
describe("getTerminalBackgroundColor", () => {
let originalIsTTY: boolean
let mockSetRawMode: ReturnType<typeof vi.fn>
let mockOn: ReturnType<typeof vi.fn>
let mockRemoveListener: ReturnType<typeof vi.fn>
let mockStdoutWrite: ReturnType<typeof vi.fn>
beforeEach(() => {
originalIsTTY = process.stdin.isTTY
mockSetRawMode = vi.fn()
mockOn = vi.fn()
mockRemoveListener = vi.fn()
mockStdoutWrite = vi.fn()
process.stdin.setRawMode = mockSetRawMode as any
process.stdin.on = mockOn as any
process.stdin.removeListener = mockRemoveListener as any
process.stdout.write = mockStdoutWrite as any
})
afterEach(() => {
process.stdin.isTTY = originalIsTTY
})
it("returns 'dark' if not in a TTY", async () => {
process.stdin.isTTY = false
const result = await getTerminalBackgroundColor()
expect(result).toBe("dark")
})
it("detects dark background from rgb: format", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
// Simulate dark terminal response (low RGB values)
setTimeout(() => {
handler(Buffer.from("\x1b]11;rgb:1000/1000/1000\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("dark")
})
it("detects light background from rgb: format", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
// Simulate light terminal response (high RGB values)
setTimeout(() => {
handler(Buffer.from("\x1b]11;rgb:f000/f000/f000\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("light")
})
it("detects dark background from hex # format", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
// Dark color: #2d2d2d
setTimeout(() => {
handler(Buffer.from("\x1b]11;#2d2d2d\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("dark")
})
it("detects light background from hex # format", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
// Light color: #f0f0f0
setTimeout(() => {
handler(Buffer.from("\x1b]11;#f0f0f0\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("light")
})
it("returns 'dark' if timeout occurs", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation(() => {
// Don't call handler - let it timeout
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("dark")
}, 2000)
it("cleans up listeners after response", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;rgb:8000/8000/8000\x07"))
}, 10)
})
await getTerminalBackgroundColor()
expect(mockSetRawMode).toHaveBeenCalledWith(false)
expect(mockRemoveListener).toHaveBeenCalled()
})
it("writes OSC 11 query sequence", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;rgb:1000/1000/1000\x07"))
}, 10)
})
await getTerminalBackgroundColor()
expect(mockStdoutWrite).toHaveBeenCalledWith("\x1b]11;?\x07")
})
it("handles luminance boundary at 0.5", async () => {
process.stdin.isTTY = true
// Test just below threshold (luminance = 0.499...)
mockOn.mockImplementationOnce((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;#7f7f7f\x07"))
}, 10)
})
const darkResult = await getTerminalBackgroundColor()
expect(darkResult).toBe("dark")
// Test just above threshold (luminance = 0.501...)
mockOn.mockImplementationOnce((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;#808080\x07"))
}, 10)
})
const lightResult = await getTerminalBackgroundColor()
expect(lightResult).toBe("light")
})
it("handles black background", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;#000000\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("dark")
})
it("handles white background", async () => {
process.stdin.isTTY = true
mockOn.mockImplementation((event: string, handler: (data: Buffer) => void) => {
setTimeout(() => {
handler(Buffer.from("\x1b]11;#ffffff\x07"))
}, 10)
})
const result = await getTerminalBackgroundColor()
expect(result).toBe("light")
})
})

View file

@ -0,0 +1,14 @@
/**
* Clipboard support using OSC 52 escape sequences.
* Works in most modern terminals without requiring system clipboard access.
*/
export function copyToClipboard(text: string): void {
const base64 = Buffer.from(text).toString("base64")
// OSC 52 clipboard sequence: \x1b]52;c;<base64>\x07
process.stdout.write(`\x1b]52;c;${base64}\x07`)
}
export function clearClipboard(): void {
process.stdout.write(`\x1b]52;c;!\x07`)
}

View file

@ -0,0 +1,32 @@
/**
* External editor support for opening $EDITOR.
*/
import { execSync } from "child_process"
import { writeFileSync, readFileSync, unlinkSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
/**
* Open the user's $EDITOR with optional initial content.
* Returns the edited text, or null if the editor was closed without saving.
*/
export function openEditor(initialContent = ""): string | null {
const editor = process.env.EDITOR || process.env.VISUAL || "vi"
const tmpFile = join(tmpdir(), `roo-edit-${Date.now()}.md`)
try {
writeFileSync(tmpFile, initialContent, "utf-8")
execSync(`${editor} ${tmpFile}`, { stdio: "inherit" })
const result = readFileSync(tmpFile, "utf-8")
return result
} catch {
return null
} finally {
try {
unlinkSync(tmpFile)
} catch {
// Ignore cleanup errors
}
}
}

View file

@ -0,0 +1,56 @@
/**
* Terminal utility functions.
*/
/**
* Detect terminal background color (dark or light).
* Uses OSC 11 query to ask the terminal for its background color.
*/
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const str = data.toString()
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
if (match) {
cleanup()
const color = match[1]!
let r = 0,
g = 0,
b = 0
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
r = parseInt(parts[0]!, 16) >> 8
g = parseInt(parts[1]!, 16) >> 8
b = parseInt(parts[2]!, 16) >> 8
} else if (color.startsWith("#")) {
r = parseInt(color.substring(1, 3), 16)
g = parseInt(color.substring(3, 5), 16)
b = parseInt(color.substring(5, 7), 16)
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
resolve(luminance > 0.5 ? "light" : "dark")
}
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}

View file

@ -11,5 +11,5 @@
}
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/ui-next"]
}

View file

@ -0,0 +1,18 @@
{
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": [],
"outDir": "dist/ui-next",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext"
},
"include": ["src/ui-next"],
"exclude": ["node_modules"]
}

View file

@ -4,7 +4,7 @@ export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
clean: false,
sourcemap: true,
target: "node20",
platform: "node",

1416
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff