mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat(cli): add ui-next Solid/OpenTUI runtime and build pipeline
This commit is contained in:
parent
5b0897beb9
commit
88092ec955
42 changed files with 6029 additions and 137 deletions
|
|
@ -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",
|
||||
|
|
|
|||
56
apps/cli/scripts/build-ui-next.ts
Normal file
56
apps/cli/scripts/build-ui-next.ts
Normal 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})`)
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
83
apps/cli/src/ui-next/app.tsx
Normal file
83
apps/cli/src/ui-next/app.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
apps/cli/src/ui-next/component/__tests__/logo-data.test.ts
Normal file
26
apps/cli/src/ui-next/component/__tests__/logo-data.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
162
apps/cli/src/ui-next/component/autocomplete/index.tsx
Normal file
162
apps/cli/src/ui-next/component/autocomplete/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
137
apps/cli/src/ui-next/component/autocomplete/triggers.ts
Normal file
137
apps/cli/src/ui-next/component/autocomplete/triggers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
16
apps/cli/src/ui-next/component/border.tsx
Normal file
16
apps/cli/src/ui-next/component/border.tsx
Normal 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>
|
||||
}
|
||||
60
apps/cli/src/ui-next/component/help-overlay.tsx
Normal file
60
apps/cli/src/ui-next/component/help-overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
apps/cli/src/ui-next/component/logo-data.ts
Normal file
56
apps/cli/src/ui-next/component/logo-data.ts
Normal 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
|
||||
}
|
||||
19
apps/cli/src/ui-next/component/logo.tsx
Normal file
19
apps/cli/src/ui-next/component/logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
501
apps/cli/src/ui-next/component/prompt/index.tsx
Normal file
501
apps/cli/src/ui-next/component/prompt/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
apps/cli/src/ui-next/component/tips.tsx
Normal file
42
apps/cli/src/ui-next/component/tips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1048
apps/cli/src/ui-next/context/__tests__/extension.test.ts
Normal file
1048
apps/cli/src/ui-next/context/__tests__/extension.test.ts
Normal file
File diff suppressed because it is too large
Load diff
55
apps/cli/src/ui-next/context/exit.tsx
Normal file
55
apps/cli/src/ui-next/context/exit.tsx
Normal 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
|
||||
},
|
||||
})
|
||||
616
apps/cli/src/ui-next/context/extension.tsx
Normal file
616
apps/cli/src/ui-next/context/extension.tsx
Normal 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 })
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
30
apps/cli/src/ui-next/context/helper.tsx
Normal file
30
apps/cli/src/ui-next/context/helper.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
84
apps/cli/src/ui-next/context/keybind.tsx
Normal file
84
apps/cli/src/ui-next/context/keybind.tsx
Normal 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())
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
25
apps/cli/src/ui-next/context/route.tsx
Normal file
25
apps/cli/src/ui-next/context/route.tsx
Normal 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>
|
||||
122
apps/cli/src/ui-next/context/theme.tsx
Normal file
122
apps/cli/src/ui-next/context/theme.tsx
Normal 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),
|
||||
}
|
||||
},
|
||||
})
|
||||
47
apps/cli/src/ui-next/context/toast.tsx
Normal file
47
apps/cli/src/ui-next/context/toast.tsx
Normal 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),
|
||||
}
|
||||
},
|
||||
})
|
||||
31
apps/cli/src/ui-next/main.tsx
Normal file
31
apps/cli/src/ui-next/main.tsx
Normal 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)
|
||||
}
|
||||
45
apps/cli/src/ui-next/routes/home.tsx
Normal file
45
apps/cli/src/ui-next/routes/home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
apps/cli/src/ui-next/routes/session/footer.tsx
Normal file
61
apps/cli/src/ui-next/routes/session/footer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/cli/src/ui-next/routes/session/header.tsx
Normal file
50
apps/cli/src/ui-next/routes/session/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
242
apps/cli/src/ui-next/routes/session/index.tsx
Normal file
242
apps/cli/src/ui-next/routes/session/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
apps/cli/src/ui-next/types.ts
Normal file
121
apps/cli/src/ui-next/types.ts
Normal 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
|
||||
44
apps/cli/src/ui-next/ui/__tests__/spinner.test.ts
Normal file
44
apps/cli/src/ui-next/ui/__tests__/spinner.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
55
apps/cli/src/ui-next/ui/dialog.tsx
Normal file
55
apps/cli/src/ui-next/ui/dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/cli/src/ui-next/ui/spinner.ts
Normal file
9
apps/cli/src/ui-next/ui/spinner.ts
Normal 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]!
|
||||
}
|
||||
33
apps/cli/src/ui-next/ui/toast.tsx
Normal file
33
apps/cli/src/ui-next/ui/toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
apps/cli/src/ui-next/util/__tests__/clipboard.test.ts
Normal file
105
apps/cli/src/ui-next/util/__tests__/clipboard.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
132
apps/cli/src/ui-next/util/__tests__/editor.test.ts
Normal file
132
apps/cli/src/ui-next/util/__tests__/editor.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
180
apps/cli/src/ui-next/util/__tests__/terminal.test.ts
Normal file
180
apps/cli/src/ui-next/util/__tests__/terminal.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
14
apps/cli/src/ui-next/util/clipboard.ts
Normal file
14
apps/cli/src/ui-next/util/clipboard.ts
Normal 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`)
|
||||
}
|
||||
32
apps/cli/src/ui-next/util/editor.ts
Normal file
32
apps/cli/src/ui-next/util/editor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/cli/src/ui-next/util/terminal.ts
Normal file
56
apps/cli/src/ui-next/util/terminal.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -11,5 +11,5 @@
|
|||
}
|
||||
},
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "src/ui-next"]
|
||||
}
|
||||
|
|
|
|||
18
apps/cli/tsconfig.ui-next.json
Normal file
18
apps/cli/tsconfig.ui-next.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -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
1416
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue