feat(cli): complete SolidJS/OpenTUI migration — remove React/Ink legacy UI

- Delete entire src/ui/ directory (~80 files of React/Ink components, hooks, stores)
- Remove react, ink, @inkjs/ui, zustand, @types/react, ink-testing-library deps
- Move @opentui/core-darwin-arm64 to optionalDependencies with all 5 platform variants
- Remove unused opentui-spinner dependency
- Chain build scripts: 'build' now runs tsup && bun build-ui-next.ts
- Clean tsup.config.ts: restore clean:true, remove React JSX config
- Clean tsconfig.json: remove jsx/jsxImportSource React settings

Architecture improvements:
- Decompose prompt God Component (502→194 LOC + 5 extracted modules)
- Extract shared extension-logic.ts (295 LOC pure functions)
- Eliminate test harness duplication, fix missing todo handling bug
- Relocate tools.ts from ui/utils/ to lib/utils/
- Port onboarding from React/Ink to pure Node.js terminal prompts
- Type dynamic import with UINextModule interface
- Replace all 'as any' casts with precise types
- Fix context-window.ts broken @/ui/store.js import
- Fix all lint warnings (unused imports, prefer-const, no-control-regex)

Verification: tsc clean, 350 tests pass, zero React/Ink/Zustand references, lint clean
This commit is contained in:
Hannes Rudolph 2026-02-06 20:26:21 -07:00
parent 88092ec955
commit 524709de5f
92 changed files with 1324 additions and 11651 deletions

View file

@ -13,7 +13,7 @@
"lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"build": "tsup && bun scripts/build-ui-next.ts",
"build:ui-next": "bun scripts/build-ui-next.ts",
"build:extension": "pnpm --filter roo-cline bundle",
"dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts -y",
@ -21,9 +21,7 @@
"clean": "rimraf dist .turbo"
},
"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:^",
@ -36,22 +34,23 @@
"cross-spawn": "^7.0.6",
"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"
"superjson": "^2.2.6"
},
"optionalDependencies": {
"@opentui/core-darwin-arm64": "^0.1.77",
"@opentui/core-darwin-x64": "^0.1.77",
"@opentui/core-linux-x64": "^0.1.77",
"@opentui/core-linux-arm64": "^0.1.77",
"@opentui/core-win32-x64": "^0.1.77"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"@types/react": "^19.1.6",
"babel-preset-solid": "^1.9.10",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"tsup": "^8.4.0",
"vitest": "^3.2.3"

View file

@ -202,7 +202,6 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
if (isTuiEnabled) {
try {
// 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")
@ -214,7 +213,16 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
}
// Dynamic import the pre-built SolidJS/opentui TUI bundle
const { startTUI } = await import(tuiBundlePath)
interface UINextModule {
startTUI: (
props: ExtensionHostOptions & {
initialPrompt?: string
version: string
createExtensionHost: (opts: ExtensionHostOptions) => ExtensionHost
},
) => Promise<void>
}
const { startTUI } = (await import(tuiBundlePath)) as UINextModule
await startTUI({
...extensionHostOptions,

View file

@ -1,128 +0,0 @@
import type { Key } from "ink"
import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js"
function createKey(overrides: Partial<Key> = {}): Key {
return {
upArrow: false,
downArrow: false,
leftArrow: false,
rightArrow: false,
pageDown: false,
pageUp: false,
home: false,
end: false,
return: false,
escape: false,
ctrl: false,
shift: false,
tab: false,
backspace: false,
delete: false,
meta: false,
...overrides,
}
}
describe("globalInputSequences", () => {
describe("GLOBAL_INPUT_SEQUENCES registry", () => {
it("should have ctrl-c registered", () => {
const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-c")
expect(seq).toBeDefined()
expect(seq?.description).toContain("Exit")
})
it("should have ctrl-m registered", () => {
const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === "ctrl-m")
expect(seq).toBeDefined()
expect(seq?.description).toContain("mode")
})
})
describe("isGlobalInputSequence", () => {
describe("Ctrl+C detection", () => {
it("should match standard Ctrl+C", () => {
const result = isGlobalInputSequence("c", createKey({ ctrl: true }))
expect(result).toBeDefined()
expect(result?.id).toBe("ctrl-c")
})
it("should not match plain 'c' key", () => {
const result = isGlobalInputSequence("c", createKey())
expect(result).toBeUndefined()
})
})
describe("Ctrl+M detection", () => {
it("should match standard Ctrl+M", () => {
const result = isGlobalInputSequence("m", createKey({ ctrl: true }))
expect(result).toBeDefined()
expect(result?.id).toBe("ctrl-m")
})
it("should match CSI u encoding for Ctrl+M", () => {
const result = isGlobalInputSequence("\x1b[109;5u", createKey())
expect(result).toBeDefined()
expect(result?.id).toBe("ctrl-m")
})
it("should match input ending with CSI u sequence", () => {
const result = isGlobalInputSequence("[109;5u", createKey())
expect(result).toBeDefined()
expect(result?.id).toBe("ctrl-m")
})
it("should not match plain 'm' key", () => {
const result = isGlobalInputSequence("m", createKey())
expect(result).toBeUndefined()
})
})
it("should return undefined for non-global sequences", () => {
const result = isGlobalInputSequence("a", createKey())
expect(result).toBeUndefined()
})
it("should return undefined for regular text input", () => {
const result = isGlobalInputSequence("hello", createKey())
expect(result).toBeUndefined()
})
})
describe("matchesGlobalSequence", () => {
it("should return true for matching sequence ID", () => {
const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-c")
expect(result).toBe(true)
})
it("should return false for non-matching sequence ID", () => {
const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "ctrl-m")
expect(result).toBe(false)
})
it("should return false for non-existent sequence ID", () => {
const result = matchesGlobalSequence("c", createKey({ ctrl: true }), "non-existent")
expect(result).toBe(false)
})
it("should match ctrl-m with CSI u encoding", () => {
const result = matchesGlobalSequence("\x1b[109;5u", createKey(), "ctrl-m")
expect(result).toBe(true)
})
})
describe("extensibility", () => {
it("should have unique IDs for all sequences", () => {
const ids = GLOBAL_INPUT_SEQUENCES.map((s) => s.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it("should have descriptions for all sequences", () => {
for (const seq of GLOBAL_INPUT_SEQUENCES) {
expect(seq.description).toBeTruthy()
expect(seq.description.length).toBeGreaterThan(0)
}
})
})
})

View file

@ -1,6 +1,10 @@
import type { ProviderSettings } from "@roo-code/types"
import type { RouterModels } from "@/ui/store.js"
/**
* Map of provider name model ID model info (including context window).
* Previously imported from the old React/Ink UI store; now defined locally.
*/
export type RouterModels = Record<string, Record<string, { contextWindow?: number }>>
const DEFAULT_CONTEXT_WINDOW = 200_000

View file

@ -1,122 +0,0 @@
/**
* Global Input Sequences Registry
*
* This module centralizes the definition of input sequences that should be
* handled at the App level (or other top-level components) and ignored by
* child components like MultilineTextInput.
*
* When adding new global shortcuts:
* 1. Add the sequence definition to GLOBAL_INPUT_SEQUENCES
* 2. The App.tsx useInput handler should check for and handle the sequence
* 3. Child components automatically ignore these via isGlobalInputSequence()
*/
import type { Key } from "ink"
/**
* Definition of a global input sequence
*/
export interface GlobalInputSequence {
/** Unique identifier for the sequence */
id: string
/** Human-readable description */
description: string
/**
* Matcher function - returns true if the input matches this sequence.
* @param input - The raw input string from useInput
* @param key - The parsed key object from useInput
*/
matches: (input: string, key: Key) => boolean
}
/**
* Registry of all global input sequences that should be handled at the App level
* and ignored by child components (like MultilineTextInput).
*
* Add new global shortcuts here to ensure they're properly handled throughout
* the application.
*/
export const GLOBAL_INPUT_SEQUENCES: GlobalInputSequence[] = [
{
id: "ctrl-c",
description: "Exit application (with confirmation)",
matches: (input, key) => key.ctrl && input === "c",
},
{
id: "ctrl-m",
description: "Cycle through modes",
matches: (input, key) => {
// Standard Ctrl+M detection
if (key.ctrl && input === "m") return true
// CSI u encoding: ESC [ 109 ; 5 u (kitty keyboard protocol)
// 109 = 'm' ASCII code, 5 = Ctrl modifier
if (input === "\x1b[109;5u") return true
if (input.endsWith("[109;5u")) return true
return false
},
},
{
id: "ctrl-t",
description: "Toggle TODO list viewer",
matches: (input, key) => {
// Standard Ctrl+T detection
if (key.ctrl && input === "t") return true
// CSI u encoding: ESC [ 116 ; 5 u (kitty keyboard protocol)
// 116 = 't' ASCII code, 5 = Ctrl modifier
if (input === "\x1b[116;5u") return true
if (input.endsWith("[116;5u")) return true
return false
},
},
// Add more global sequences here as needed:
// {
// id: "ctrl-n",
// description: "New task",
// matches: (input, key) => key.ctrl && input === "n",
// },
]
/**
* Check if an input matches any global input sequence.
*
* Use this in child components (like MultilineTextInput) to determine
* if input should be ignored because it will be handled by a parent component.
*
* @param input - The raw input string from useInput
* @param key - The parsed key object from useInput
* @returns The matching GlobalInputSequence, or undefined if no match
*
* @example
* ```tsx
* useInput((input, key) => {
* // Ignore inputs handled at App level
* if (isGlobalInputSequence(input, key)) {
* return
* }
* // Handle component-specific input...
* })
* ```
*/
export function isGlobalInputSequence(input: string, key: Key): GlobalInputSequence | undefined {
return GLOBAL_INPUT_SEQUENCES.find((seq) => seq.matches(input, key))
}
/**
* Check if an input matches a specific global input sequence by ID.
*
* @param input - The raw input string from useInput
* @param key - The parsed key object from useInput
* @param id - The sequence ID to check for
* @returns true if the input matches the specified sequence
*
* @example
* ```tsx
* if (matchesGlobalSequence(input, key, "ctrl-m")) {
* // Handle mode cycling
* }
* ```
*/
export function matchesGlobalSequence(input: string, key: Key, id: string): boolean {
const seq = GLOBAL_INPUT_SEQUENCES.find((s) => s.id === id)
return seq ? seq.matches(input, key) : false
}

View file

@ -1,38 +1,149 @@
import { createElement } from "react"
import { createInterface } from "node:readline"
import { type OnboardingResult, OnboardingProviderChoice } from "@/types/index.js"
import { type OnboardingResult, OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js"
import { login } from "@/commands/index.js"
import { saveSettings } from "@/lib/storage/index.js"
export async function runOnboarding(): Promise<OnboardingResult> {
const { render } = await import("ink")
const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
// ANSI helpers
const CYAN = "\x1b[36m"
const BOLD = "\x1b[1m"
const DIM = "\x1b[2m"
const RESET = "\x1b[0m"
const HIDE_CURSOR = "\x1b[?25l"
const SHOW_CURSOR = "\x1b[?25h"
return new Promise<OnboardingResult>((resolve) => {
const onSelect = async (choice: OnboardingProviderChoice) => {
await saveSettings({ onboardingProviderChoice: choice })
interface MenuOption<T> {
label: string
value: T
}
app.unmount()
/**
* Render a terminal select menu with arrow-key navigation.
* Returns the selected value when the user presses Enter.
*/
function terminalSelect<T>(prompt: string, options: MenuOption<T>[]): Promise<T> {
return new Promise<T>((resolve, reject) => {
const { stdin, stdout } = process
console.log("")
if (!stdin.isTTY) {
// Non-interactive fallback: use readline to read a number
const rl = createInterface({ input: stdin, output: stdout })
stdout.write(`${prompt}\n`)
options.forEach((opt, i) => stdout.write(` ${i + 1}) ${opt.label}\n`))
rl.question("Enter choice (number): ", (answer) => {
rl.close()
const idx = parseInt(answer, 10) - 1
const selected = options[idx]
if (idx >= 0 && idx < options.length && selected) {
resolve(selected.value)
} else {
reject(new Error(`Invalid choice: ${answer}`))
}
})
return
}
if (choice === OnboardingProviderChoice.Roo) {
const result = await login()
await saveSettings({ onboardingProviderChoice: choice })
let selectedIndex = 0
resolve({
choice: OnboardingProviderChoice.Roo,
token: result.success ? result.token : undefined,
skipped: false,
})
} else {
console.log("Using your own API key.")
console.log("Set your API key via --api-key or environment variable.")
console.log("")
resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
function render() {
// Move cursor up to overwrite previous render (except on first render)
const lines = options.length
if (rendered) {
stdout.write(`\x1b[${lines}A`)
}
for (const [i, opt] of options.entries()) {
const prefix = i === selectedIndex ? `${CYAN}${RESET} ` : " "
const label = i === selectedIndex ? `${BOLD}${opt.label}${RESET}` : `${DIM}${opt.label}${RESET}`
stdout.write(`\x1b[2K${prefix}${label}\n`)
}
}
const app = render(createElement(OnboardingScreen, { onSelect }))
let rendered = false
function onData(data: Buffer) {
const key = data.toString()
// Up arrow: \x1b[A or k
if (key === "\x1b[A" || key === "k") {
selectedIndex = (selectedIndex - 1 + options.length) % options.length
render()
return
}
// Down arrow: \x1b[B or j
if (key === "\x1b[B" || key === "j") {
selectedIndex = (selectedIndex + 1) % options.length
render()
return
}
// Enter
if (key === "\r" || key === "\n") {
cleanup()
const selected = options[selectedIndex]
if (selected) {
resolve(selected.value)
}
return
}
// Ctrl+C
if (key === "\x03") {
cleanup()
reject(new Error("User cancelled"))
return
}
}
function cleanup() {
stdin.removeListener("data", onData)
stdin.setRawMode(false)
stdin.pause()
stdout.write(SHOW_CURSOR)
}
// Setup raw mode for keypress detection
stdout.write(HIDE_CURSOR)
stdout.write(`${prompt}\n`)
stdin.setRawMode(true)
stdin.resume()
stdin.on("data", onData)
render()
rendered = true
})
}
export async function runOnboarding(): Promise<OnboardingResult> {
// Display ASCII art header
process.stdout.write(`\n${BOLD}${CYAN}${ASCII_ROO}${RESET}\n\n`)
const choice = await terminalSelect<OnboardingProviderChoice>(
`${DIM}Welcome! How would you like to connect to an LLM provider?${RESET}\n`,
[
{ label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo },
{ label: "Bring your own API key", value: OnboardingProviderChoice.Byok },
],
)
await saveSettings({ onboardingProviderChoice: choice })
console.log("")
if (choice === OnboardingProviderChoice.Roo) {
const result = await login()
await saveSettings({ onboardingProviderChoice: choice })
return {
choice: OnboardingProviderChoice.Roo,
token: result.success ? result.token : undefined,
skipped: false,
}
}
console.log("Using your own API key.")
console.log("Set your API key via --api-key or environment variable.")
console.log("")
return { choice: OnboardingProviderChoice.Byok, skipped: false }
}

View file

@ -1,6 +1,6 @@
import type { TodoItem } from "@roo-code/types"
import type { ToolData } from "../types.js"
import type { ToolData } from "../../ui-next/types.js"
/**
* Extract structured ToolData from parsed tool JSON

View file

@ -2,7 +2,7 @@
* Tests for trigger detection logic.
*/
import { detectTrigger, formatRelativeTime, truncateText, getReplacementText } from "../triggers.js"
import { detectTrigger, formatRelativeTime, truncateText, getReplacementText, type TriggerType } from "../triggers.js"
describe("detectTrigger", () => {
it("returns null for empty input", () => {
@ -187,8 +187,8 @@ describe("getReplacementText", () => {
})
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")
// Cast to test the default branch with an unknown type
expect(getReplacementText("unknown" as TriggerType, "val", "current", 0)).toBe("current")
})
})

View file

@ -0,0 +1,125 @@
/**
* Autocomplete item builder functions.
*
* Each function takes a search query and dependencies, then returns
* a list of autocomplete items. They do not own any state the caller
* is responsible for setting the resulting items into signals.
*/
import fuzzysort from "fuzzysort"
import type { AutocompleteItem } from "../autocomplete/index.js"
import { formatRelativeTime, truncateText } from "../autocomplete/triggers.js"
import { getGlobalCommandsForAutocomplete } from "../../../lib/utils/commands.js"
import type { SlashCommandResult, ModeResult, TaskHistoryItem } from "../../types.js"
// ----------------------------------------------------------------
// Slash commands
// ----------------------------------------------------------------
/** Build autocomplete items for slash commands (global CLI + extension). */
export function buildSlashCommandItems(query: string, allSlashCommands: SlashCommandResult[]): AutocompleteItem[] {
const globalCmds = getGlobalCommandsForAutocomplete().map((c) => ({
key: c.name,
label: `/${c.name}`,
description: c.description,
icon: c.action ? "⚙️" : "🌐",
}))
const extCmds = (allSlashCommands || []).map((c) => ({
key: c.key || c.label,
label: `/${c.label}`,
description: c.description,
icon: "⚡",
}))
let all = [...globalCmds, ...extCmds]
if (query.length > 0) {
const results = fuzzysort.go(query, all, {
key: "label",
limit: 20,
threshold: -10000,
})
all = results.map((r) => r.obj)
} else {
all = all.slice(0, 20)
}
return all
}
// ----------------------------------------------------------------
// Modes
// ----------------------------------------------------------------
/** Build autocomplete items for mode switching. */
export function buildModeItems(query: string, availableModes: ModeResult[]): AutocompleteItem[] {
let modes = (availableModes || []).map((m) => ({
key: m.key || m.slug,
label: m.label,
description: m.slug,
icon: "🔧",
}))
if (query.length > 0) {
const results = fuzzysort.go(query, modes, {
key: "label",
limit: 20,
threshold: -10000,
})
modes = results.map((r) => r.obj)
}
return modes
}
// ----------------------------------------------------------------
// History
// ----------------------------------------------------------------
/** Build autocomplete items for task history. */
export function buildHistoryItems(query: string, taskHistory: TaskHistoryItem[]): AutocompleteItem[] {
let history = (taskHistory || [])
.sort((a, b) => b.ts - a.ts)
.map((h) => ({
key: h.id,
label: truncateText(h.task.replace(/\n/g, " "), 55),
meta: formatRelativeTime(h.ts),
icon: h.status === "completed" ? "✓" : h.status === "active" ? "●" : "○",
}))
if (query.length > 0) {
const results = fuzzysort.go(query, history, {
key: "label",
limit: 15,
threshold: -10000,
})
history = results.map((r) => r.obj)
} else {
history = history.slice(0, 15)
}
return history
}
// ----------------------------------------------------------------
// File search
// ----------------------------------------------------------------
/**
* Triggers a debounced file search.
*
* Returns a new timer handle. The caller must store and clear it on cleanup.
*/
export function triggerFileSearch(
query: string,
searchFiles: (q: string) => void,
existingTimer: ReturnType<typeof setTimeout> | undefined,
): ReturnType<typeof setTimeout> {
if (existingTimer) clearTimeout(existingTimer)
return setTimeout(() => {
searchFiles(query)
}, 150)
}

View file

@ -0,0 +1,105 @@
/**
* Autocomplete selection and dismissal handlers.
*
* Pure functions that execute the side-effects of selecting or dismissing
* an autocomplete item: replacing text, switching modes, resuming tasks, etc.
*/
import { batch } from "solid-js"
import type { TextareaRenderable } from "@opentui/core"
import type { WebviewMessage } from "@roo-code/types"
import type { AutocompleteItem } from "../autocomplete/index.js"
import { getReplacementText, type TriggerDetection } from "../autocomplete/triggers.js"
/** Dependencies required by the autocomplete select handler. */
export interface AutocompleteSelectContext {
textareaRef: TextareaRenderable | undefined
currentText: () => string
activeTrigger: () => TriggerDetection | null
setActiveTrigger: (v: TriggerDetection | null) => void
setShowHelp: (v: boolean) => void
setAutocompleteItems: (v: AutocompleteItem[]) => void
sendToExtension: (msg: WebviewMessage) => void
toastInfo: (msg: string) => void
}
/** Handle the user selecting an item from the autocomplete overlay. */
export function handleAutocompleteSelect(item: AutocompleteItem, _index: number, ctx: AutocompleteSelectContext): void {
const trigger = ctx.activeTrigger()
if (!trigger || !ctx.textareaRef) return
switch (trigger.type) {
case "slash": {
// Replace trigger text with selected command
const cmdName = item.label.startsWith("/") ? item.label.substring(1) : item.label
const replacement = getReplacementText("slash", cmdName, ctx.currentText(), trigger.triggerIndex)
ctx.textareaRef.clear()
if (replacement) ctx.textareaRef.insertText(replacement)
break
}
case "file": {
// Replace trigger text with file path
const replacement = getReplacementText("file", item.label, ctx.currentText(), trigger.triggerIndex)
ctx.textareaRef.clear()
if (replacement) ctx.textareaRef.insertText(replacement)
break
}
case "mode": {
// Switch mode via extension
const modeSlug = item.description || item.key
ctx.sendToExtension({ type: "mode", text: modeSlug })
ctx.toastInfo(`Switched to ${item.label}`)
ctx.textareaRef.clear()
break
}
case "history": {
// Resume task from history
const taskId = item.key
ctx.sendToExtension({ type: "showTaskWithId", text: taskId })
ctx.toastInfo("Resuming task...")
ctx.textareaRef.clear()
break
}
case "help": {
// Help items insert their trigger char
const shortcut = item.label.trim()
ctx.textareaRef.clear()
if (["Esc", "Tab", "Ctrl+M", "Ctrl+C", "Alt+Enter"].includes(shortcut)) {
// Action shortcuts — just clear
} else {
// Trigger shortcuts — insert the trigger char
ctx.textareaRef.insertText(shortcut)
}
break
}
}
// Clear autocomplete state
batch(() => {
ctx.setActiveTrigger(null)
ctx.setShowHelp(false)
ctx.setAutocompleteItems([])
})
}
/** Dependencies required by the autocomplete dismiss handler. */
export interface AutocompleteDismissContext {
textareaRef: TextareaRenderable | undefined
setActiveTrigger: (v: TriggerDetection | null) => void
setShowHelp: (v: boolean) => void
setAutocompleteItems: (v: AutocompleteItem[]) => void
}
/** Handle dismissing the autocomplete overlay. */
export function handleAutocompleteDismiss(ctx: AutocompleteDismissContext): void {
batch(() => {
ctx.setActiveTrigger(null)
ctx.setShowHelp(false)
ctx.setAutocompleteItems([])
})
// Clear trigger char from input
if (ctx.textareaRef) {
ctx.textareaRef.clear()
}
}

View file

@ -0,0 +1,51 @@
/**
* Pure functions for computing autocomplete display state.
*
* Maps trigger types to UI strings (title, empty message)
* and determines autocomplete overlay visibility.
*/
import type { TriggerDetection } from "../autocomplete/triggers.js"
/** Maps a trigger type to the autocomplete overlay title. */
export function getAutocompleteTitle(trigger: TriggerDetection | null): string {
if (!trigger) return ""
switch (trigger.type) {
case "slash":
return "Commands"
case "file":
return "Files"
case "mode":
return "Modes"
case "history":
return "Task History"
case "help":
return "Help"
default:
return ""
}
}
/** Maps a trigger type to the empty-state text shown when no items match. */
export function getAutocompleteEmpty(trigger: TriggerDetection | null): string {
if (!trigger) return "No results"
switch (trigger.type) {
case "slash":
return "No matching commands"
case "file":
return "No matching files"
case "mode":
return "No matching modes"
case "history":
return "No task history"
case "help":
return "No shortcuts"
default:
return "No results"
}
}
/** Determines whether the autocomplete overlay should be visible (excludes help trigger). */
export function shouldShowAutocomplete(trigger: TriggerDetection | null): boolean {
return trigger !== null && trigger.type !== "help"
}

View file

@ -1,16 +1,8 @@
/**
* 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.
*/
/** Prompt input component — orchestrator wiring keybindings, autocomplete, and keyboard modules. */
import { createSignal, createEffect, createMemo, on, onCleanup, Show, batch } from "solid-js"
import { type TextareaRenderable, type KeyBinding, type KeyEvent } from "@opentui/core"
import { createSignal, createEffect, createMemo, on, onCleanup } from "solid-js"
import { type TextareaRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { useTheme } from "../../context/theme.js"
import { useExit } from "../../context/exit.js"
@ -18,21 +10,17 @@ import { useToast } from "../../context/toast.js"
import { useExtension } from "../../context/extension.js"
import { AutocompleteOverlay, type AutocompleteItem } from "../autocomplete/index.js"
import { HelpOverlay } from "../help-overlay.js"
import { detectTrigger, type TriggerDetection } from "../autocomplete/triggers.js"
import { PROMPT_KEYBINDINGS } from "./keybindings.js"
import {
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" },
]
buildSlashCommandItems,
buildModeItems,
buildHistoryItems,
triggerFileSearch,
} from "./autocomplete-builders.js"
import { handleAutocompleteSelect, handleAutocompleteDismiss } from "./autocomplete-handlers.js"
import { getAutocompleteTitle, getAutocompleteEmpty, shouldShowAutocomplete } from "./autocomplete-memos.js"
import { createPromptKeyboardHandler } from "./keyboard-handler.js"
export interface PromptRef {
getText: () => string
@ -46,7 +34,6 @@ export interface PromptProps {
isActive?: boolean
prefix?: string
ref?: (ref: PromptRef) => void
/** Enable trigger detection for autocomplete overlays */
enableTriggers?: boolean
}
@ -55,47 +42,33 @@ export function Prompt(props: PromptProps) {
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
const exitTimerRef: { current: ReturnType<typeof setTimeout> | undefined } = { current: undefined }
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (exitTimer) clearTimeout(exitTimer)
if (exitTimerRef.current) clearTimeout(exitTimerRef.current)
if (fileSearchTimer) clearTimeout(fileSearchTimer)
})
// ================================================================
// Trigger detection — runs whenever text changes
// ================================================================
createEffect(
on(currentText, (text) => {
if (!props.enableTriggers) return
const trigger = detectTrigger(text)
if (!trigger) {
setActiveTrigger(null)
setShowHelp(false)
setAutocompleteItems([])
return
}
setActiveTrigger(trigger)
switch (trigger.type) {
case "help":
setShowHelp(true)
@ -103,28 +76,24 @@ export function Prompt(props: PromptProps) {
break
case "slash":
setShowHelp(false)
updateSlashCommandItems(trigger.query)
setAutocompleteItems(buildSlashCommandItems(trigger.query, ext.state.allSlashCommands))
break
case "file":
setShowHelp(false)
triggerFileSearch(trigger.query)
fileSearchTimer = triggerFileSearch(trigger.query, ext.searchFiles.bind(ext), fileSearchTimer)
break
case "mode":
setShowHelp(false)
updateModeItems(trigger.query)
setAutocompleteItems(buildModeItems(trigger.query, ext.state.availableModes))
break
case "history":
setShowHelp(false)
updateHistoryItems(trigger.query)
setAutocompleteItems(buildHistoryItems(trigger.query, ext.state.taskHistory))
break
}
}),
)
// ================================================================
// Update autocomplete items when extension state changes
// ================================================================
// Refresh file search results when they arrive from extension
createEffect(
on(
@ -132,342 +101,87 @@ export function Prompt(props: PromptProps) {
(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)
setAutocompleteItems(
results.map((r) => ({ key: r.key || r.path, label: r.path || r.label, icon: "📄" })),
)
}
},
),
)
// ================================================================
// 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)
// Autocomplete handler contexts
const selectCtx = {
get textareaRef() {
return textareaRef
},
currentText,
activeTrigger,
setActiveTrigger,
setShowHelp,
setAutocompleteItems,
sendToExtension: ext.sendToExtension.bind(ext),
toastInfo: toast.info,
}
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)
const dismissCtx = {
get textareaRef() {
return textareaRef
},
setActiveTrigger,
setShowHelp,
setAutocompleteItems,
}
const onSelect = (item: AutocompleteItem, index: number) => handleAutocompleteSelect(item, index, selectCtx)
const onDismiss = () => handleAutocompleteDismiss(dismissCtx)
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
// ================================================================
// Keyboard handler
useKeyboard(
createPromptKeyboardHandler({
isActive: () => props.isActive ?? false,
hasOverlay: () => activeTrigger() !== null || showHelp(),
dismissOverlay: onDismiss,
isLoading: () => ext.state.isLoading,
availableModes: () => ext.state.availableModes || [],
currentMode: () => ext.state.currentMode,
sendToExtension: ext.sendToExtension.bind(ext),
toastInfo: toast.info,
toastWarning: toast.warning,
exit,
pendingExit,
setPendingExit,
exitTimer: exitTimerRef,
}),
)
function handleSubmit() {
if (!textareaRef) return
const text = textareaRef.plainText.trim()
if (!text) return
// If 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
// ================================================================
const autocompleteTitle = createMemo(() => getAutocompleteTitle(activeTrigger()))
const autocompleteEmpty = createMemo(() => getAutocompleteEmpty(activeTrigger()))
const showAutocomplete = createMemo(() => shouldShowAutocomplete(activeTrigger()))
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}
onSelect={onSelect}
onDismiss={onDismiss}
/>
{/* Prompt input row */}
<box flexDirection="row" flexShrink={0}>
<text fg={props.isActive ? theme.promptColorActive : theme.promptColor} flexShrink={0}>
{props.prefix ?? " "}

View file

@ -0,0 +1,13 @@
/**
* Default key bindings for the prompt textarea.
*
* Enter submits the prompt, Option+Enter inserts a newline.
*/
import type { KeyBinding } from "@opentui/core"
/** Default key bindings: Enter submits, Option+Enter for newline */
export const PROMPT_KEYBINDINGS: KeyBinding[] = [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
]

View file

@ -0,0 +1,108 @@
/**
* Factory for the global keyboard handler used in the prompt.
*
* Creates the callback passed to opentui's `useKeyboard()`, handling:
* - Esc: dismiss overlays or cancel the current task
* - Ctrl+C: double-press to exit
* - Ctrl+M: cycle through available modes
*/
import type { KeyEvent } from "@opentui/core"
import type { WebviewMessage } from "@roo-code/types"
import type { ModeResult } from "../../types.js"
/** Dependencies required by the prompt keyboard handler. */
export interface PromptKeyboardContext {
isActive: () => boolean
hasOverlay: () => boolean
dismissOverlay: () => void
// Extension state
isLoading: () => boolean
availableModes: () => ModeResult[]
currentMode: () => string | null
sendToExtension: (msg: WebviewMessage) => void
// Toast
toastInfo: (msg: string) => void
toastWarning: (msg: string) => void
// Exit
exit: () => void
pendingExit: () => boolean
setPendingExit: (v: boolean) => void
exitTimer: { current: ReturnType<typeof setTimeout> | undefined }
}
/**
* Create the keyboard handler callback for the prompt component.
*
* Returns a function suitable for passing to `useKeyboard()`.
*/
export function createPromptKeyboardHandler(ctx: PromptKeyboardContext): (event: KeyEvent) => void {
return (event: KeyEvent) => {
if (!ctx.isActive()) return
const hasOverlay = ctx.hasOverlay()
// Esc — cancel task or dismiss overlay
if (event.name === "escape") {
if (hasOverlay) {
ctx.dismissOverlay()
return
}
// Cancel current task when loading
if (ctx.isLoading()) {
ctx.sendToExtension({ type: "cancelTask" })
ctx.toastInfo("Cancelling task...")
return
}
}
// Ctrl+C — double-press to exit
if (event.name === "c" && event.ctrl) {
if (hasOverlay) {
ctx.dismissOverlay()
return
}
if (ctx.pendingExit()) {
// Second press — exit
if (ctx.exitTimer.current) clearTimeout(ctx.exitTimer.current)
ctx.exit()
return
}
// First press — show hint
ctx.setPendingExit(true)
ctx.toastWarning("Press Ctrl+C again to exit")
ctx.exitTimer.current = setTimeout(() => {
ctx.setPendingExit(false)
}, 2000)
return
}
// Ctrl+M — cycle through modes
if (event.name === "m" && event.ctrl) {
if (ctx.isLoading()) {
ctx.toastWarning("Cannot switch modes while task is in progress")
return
}
const modes = ctx.availableModes()
if (modes.length < 2) return
const currentSlug = ctx.currentMode()
const currentIndex = modes.findIndex((m) => m.slug === currentSlug)
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % modes.length
const nextMode = modes[nextIndex]
if (nextMode) {
ctx.sendToExtension({ type: "mode", text: nextMode.slug })
ctx.toastInfo(`Switched to ${nextMode.label}`)
}
return
}
}
}

View file

@ -1,9 +1,9 @@
/**
* Tests for extension context message handling and state management logic.
*
* Since extension.tsx uses SolidJS JSX (createStore, batch, etc.) which requires
* a SolidJS compilation pipeline not available in the standard vitest config,
* we test the core logic by replicating the state management patterns in plain TS.
* The core message-processing logic lives in extension-logic.ts as pure functions.
* This test harness applies the results of those functions to a plain store object,
* mirroring how extension.tsx applies them via SolidJS setStore().
*
* This tests:
* 1. handleSayMessage behavior (message filtering, role assignment, dedup)
@ -16,7 +16,7 @@
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
vi.mock("../../../ui/utils/tools.js", () => ({
vi.mock("../../../lib/utils/tools.js", () => ({
extractToolData: vi.fn((toolInfo: Record<string, unknown>) => ({
tool: toolInfo.tool as string,
path: toolInfo.path as string | undefined,
@ -34,41 +34,23 @@ vi.mock("../../../lib/utils/commands.js", () => ({
getGlobalCommandsForAutocomplete: vi.fn(() => []),
}))
import { formatToolAskMessage, parseTodosFromToolInfo } from "../../../lib/utils/tools.js"
import type { TUIMessage, PendingAsk } from "../../types.js"
import {
extractToolData,
formatToolOutput,
formatToolAskMessage,
parseTodosFromToolInfo,
} from "../../../ui/utils/tools.js"
import { getGlobalCommand } from "../../../lib/utils/commands.js"
processSayMessage,
processAskMessage,
computeSubmitAction,
type MessageContext,
type SayMessageResult,
type AskMessageResult,
} from "../extension-logic.js"
// ================================================================
// Types mirrored from extension.tsx and types.ts
// ExtensionStore type (simplified version for tests — avoids
// importing from extension.tsx which requires SolidJS compilation)
// ================================================================
type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
interface TUIMessage {
id: string
role: MessageRole
content: string
toolName?: string
toolDisplayName?: string
toolDisplayOutput?: string
partial?: boolean
originalType?: ClineAsk | ClineSay
toolData?: Record<string, unknown>
todos?: TodoItem[]
previousTodos?: TodoItem[]
}
interface PendingAsk {
id: string
type: ClineAsk
content: string
suggestions?: Array<{ answer: string; mode?: string | null }>
}
interface ExtensionStore {
messages: TUIMessage[]
pendingAsk: PendingAsk | null
@ -89,12 +71,14 @@ interface ExtensionStore {
}
// ================================================================
// Replicate extension context logic as testable class
// Test harness — thin wrapper that delegates to pure functions
// from extension-logic.ts and applies the results to a plain store.
// ================================================================
/**
* Portable replica of the extension context state machine.
* This mirrors all the pure logic in extension.tsx without SolidJS dependencies.
* Portable test harness that uses the shared pure functions from
* extension-logic.ts. This ensures test and production code execute
* the same business logic.
*/
class ExtensionContextTestHarness {
store: ExtensionStore = {
@ -133,7 +117,19 @@ class ExtensionContextTestHarness {
this.sentMessages.push(msg)
}
/** Mirror of addMessage from extension.tsx */
/** Build a MessageContext snapshot for pure functions. */
private get messageContext(): MessageContext {
return {
seenMessageIds: this.seenMessageIds,
firstTextMessageSkipped: this.firstTextMessageSkipped,
isResumingTask: this.store.isResumingTask,
pendingCommandRef: this.pendingCommandRef,
nonInteractive: this.nonInteractive,
currentTodos: this.store.currentTodos,
}
}
/** Add or update a message in the store (no streaming debounce in tests). */
addMessage(msg: TUIMessage) {
const existingIndex = this.store.messages.findIndex((m) => m.id === msg.id)
@ -156,255 +152,110 @@ class ExtensionContextTestHarness {
this.store.messages = msgs
}
/** Mirror of handleSayMessage from extension.tsx */
/** Apply result from processSayMessage to the store. */
private applySayResult(result: SayMessageResult) {
if (result.trackId) this.seenMessageIds.add(result.trackId)
if (result.setFirstTextSkipped) this.firstTextMessageSkipped = true
if (result.clearPendingCommand) this.pendingCommandRef = null
if (result.message) this.addMessage(result.message)
}
/** Apply result from processAskMessage to the store. */
private applyAskResult(result: AskMessageResult) {
if (result.trackId) this.seenMessageIds.add(result.trackId)
if (result.pendingCommand !== undefined) this.pendingCommandRef = result.pendingCommand
const u = result.storeUpdates
if (u.isLoading !== undefined) this.store.isLoading = u.isLoading
if (u.hasStartedTask !== undefined) this.store.hasStartedTask = u.hasStartedTask
if (u.isResumingTask !== undefined) this.store.isResumingTask = u.isResumingTask
if (u.isComplete !== undefined) this.store.isComplete = u.isComplete
if (result.todoUpdate) {
this.store.previousTodos = result.todoUpdate.previousTodos
this.store.currentTodos = result.todoUpdate.currentTodos
}
if (result.pendingAsk) this.store.pendingAsk = result.pendingAsk
if (result.message) this.addMessage(result.message)
}
/** Delegates to processSayMessage pure 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") this.seenMessageIds.add(messageId)
return
}
if (say === "text" && !this.firstTextMessageSkipped && !this.store.isResumingTask) {
this.firstTextMessageSkipped = true
this.seenMessageIds.add(messageId)
return
}
if (this.seenMessageIds.has(messageId) && !partial) return
let role: MessageRole = "assistant"
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let toolData: Record<string, unknown> | undefined
if (say === "command_output") {
role = "tool"
toolName = "execute_command"
toolDisplayName = "bash"
toolDisplayOutput = text
const trackedCommand = this.pendingCommandRef
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
this.pendingCommandRef = null
} else if (say === "reasoning") {
role = "thinking"
}
this.seenMessageIds.add(messageId)
this.addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
})
this.applySayResult(processSayMessage(this.messageContext, ts, say, text, partial))
}
/** Mirror of handleAskMessage from extension.tsx */
/** Delegates to processAskMessage pure function. */
handleAskMessage(ts: number, ask: ClineAsk, text: string, partial: boolean) {
const messageId = ts.toString()
if (partial) return
if (this.seenMessageIds.has(messageId)) return
if (ask === "command_output") {
this.seenMessageIds.add(messageId)
return
}
if (ask === "resume_task" || ask === "resume_completed_task") {
this.seenMessageIds.add(messageId)
this.store.isLoading = false
this.store.hasStartedTask = true
this.store.isResumingTask = false
return
}
if (ask === "completion_result") {
this.seenMessageIds.add(messageId)
this.store.isComplete = true
this.store.isLoading = false
try {
const completionInfo = JSON.parse(text) as Record<string, unknown>
const toolDataVal = {
tool: "attempt_completion",
result: completionInfo.result as string | undefined,
content: completionInfo.result as string | undefined,
}
this.addMessage({
id: messageId,
role: "tool",
content: text,
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: (formatToolOutput as ReturnType<typeof vi.fn>)({
tool: "attempt_completion",
...completionInfo,
}),
originalType: ask,
toolData: toolDataVal,
})
} catch {
this.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") {
this.pendingCommandRef = text
}
if (this.nonInteractive && ask !== "followup") {
this.seenMessageIds.add(messageId)
if (ask === "tool") {
let localToolName: string | undefined
let localToolDisplayName: string | undefined
let localToolDisplayOutput: string | undefined
let formattedContent = text || ""
let localToolData: Record<string, unknown> | undefined
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
localToolName = toolInfo.tool as string
localToolDisplayName = toolInfo.tool as string
localToolDisplayOutput = (formatToolOutput as ReturnType<typeof vi.fn>)(toolInfo)
formattedContent = (formatToolAskMessage as ReturnType<typeof vi.fn>)(toolInfo)
localToolData = (extractToolData as ReturnType<typeof vi.fn>)(toolInfo)
} catch {
// Use raw text
}
this.addMessage({
id: messageId,
role: "tool",
content: formattedContent,
toolName: localToolName,
toolDisplayName: localToolDisplayName,
toolDisplayOutput: localToolDisplayOutput,
originalType: ask,
toolData: localToolData,
})
} else {
this.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 as ReturnType<typeof vi.fn>)(toolInfo)
} catch {
// Use raw text
}
}
this.seenMessageIds.add(messageId)
this.store.pendingAsk = {
id: messageId,
type: ask,
content: questionText,
suggestions,
}
this.applyAskResult(processAskMessage(this.messageContext, ts, ask, text, partial))
}
/** Mirror of handleSubmit from extension.tsx */
/** Delegates to computeSubmitAction pure function, then applies. */
async handleSubmit(text: string) {
if (!text.trim()) return
const action = computeSubmitAction(
{
pendingAsk: this.store.pendingAsk,
hasStartedTask: this.store.hasStartedTask,
isComplete: this.store.isComplete,
},
text,
() => `uuid-${Date.now()}`,
)
const trimmedText = text.trim()
if (trimmedText === "__CUSTOM__") return
switch (action.kind) {
case "none":
return
// Check for CLI global action commands
if (trimmedText.startsWith("/")) {
const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/)
if (commandMatch && commandMatch[1]) {
const globalCommand = (getGlobalCommand as ReturnType<typeof vi.fn>)(commandMatch[1])
if (globalCommand?.action === "clearTask") {
this.store.messages = []
this.store.pendingAsk = null
this.store.isLoading = false
this.store.isComplete = false
this.store.hasStartedTask = false
this.store.error = null
this.store.isResumingTask = false
this.store.tokenUsage = null
this.store.currentTodos = []
this.store.previousTodos = []
this.seenMessageIds.clear()
this.firstTextMessageSkipped = false
this.sendToExtension({ type: "clearTask" })
this.sendToExtension({ type: "requestCommands" })
this.sendToExtension({ type: "requestModes" })
return
}
}
}
if (this.store.pendingAsk) {
this.addMessage({ id: `uuid-${Date.now()}`, role: "user", content: trimmedText })
this.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
this.store.pendingAsk = null
this.store.isLoading = true
} else if (!this.store.hasStartedTask) {
this.store.hasStartedTask = true
this.store.isLoading = true
this.addMessage({ id: `uuid-${Date.now()}`, role: "user", content: trimmedText })
try {
this.runTaskCalls.push(trimmedText)
if (this.runTaskError) throw this.runTaskError
} catch (err) {
this.store.error = err instanceof Error ? err.message : String(err)
case "clearTask":
this.store.messages = []
this.store.pendingAsk = null
this.store.isLoading = false
}
} else {
if (this.store.isComplete) this.store.isComplete = false
this.store.isLoading = true
this.addMessage({ id: `uuid-${Date.now()}`, role: "user", content: trimmedText })
this.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
this.store.isComplete = false
this.store.hasStartedTask = false
this.store.error = null
this.store.isResumingTask = false
this.store.tokenUsage = null
this.store.currentTodos = []
this.store.previousTodos = []
this.seenMessageIds.clear()
this.firstTextMessageSkipped = false
this.sendToExtension({ type: "clearTask" })
this.sendToExtension({ type: "requestCommands" })
this.sendToExtension({ type: "requestModes" })
return
case "respondToAsk":
this.addMessage(action.userMessage)
this.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: action.text,
})
this.store.pendingAsk = null
this.store.isLoading = true
return
case "startNewTask":
this.store.hasStartedTask = true
this.store.isLoading = true
this.addMessage(action.userMessage)
try {
this.runTaskCalls.push(action.text)
if (this.runTaskError) throw this.runTaskError
} catch (err) {
this.store.error = err instanceof Error ? err.message : String(err)
this.store.isLoading = false
}
return
case "continueTask":
if (this.store.isComplete) this.store.isComplete = false
this.store.isLoading = true
this.addMessage(action.userMessage)
this.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: action.text,
})
return
}
}
@ -729,6 +580,31 @@ describe("Extension Context Logic", () => {
expect(nonInteractiveCtx.store.messages[0]!.content).toBe("invalid json")
expect(nonInteractiveCtx.store.messages[0]!.toolName).toBeUndefined()
})
it("handles update_todo_list tool by updating todos in store", () => {
const mockTodos: TodoItem[] = [
{ id: "1", content: "Fix bug", status: "in_progress" },
{ id: "2", content: "Write tests", status: "pending" },
]
vi.mocked(parseTodosFromToolInfo).mockReturnValueOnce(mockTodos)
// Set some existing todos
nonInteractiveCtx.store.currentTodos = [{ id: "0", content: "Old todo", status: "completed" }]
const toolInfo = JSON.stringify({ tool: "update_todo_list", todos: "..." })
nonInteractiveCtx.handleAskMessage(8004, "tool", toolInfo, false)
// Store todos should be updated
expect(nonInteractiveCtx.store.currentTodos).toEqual(mockTodos)
expect(nonInteractiveCtx.store.previousTodos).toEqual([
{ id: "0", content: "Old todo", status: "completed" },
])
// Message should contain todo data
const msg = nonInteractiveCtx.store.messages[0]!
expect(msg.todos).toEqual(mockTodos)
expect(msg.previousTodos).toEqual([{ id: "0", content: "Old todo", status: "completed" }])
})
})
})

View file

@ -0,0 +1,390 @@
/**
* Pure business logic for extension message processing.
*
* These functions have NO SolidJS dependencies and NO side effects.
* They compute state transitions and return result objects that callers apply
* via their own state management (SolidJS `setStore()` in production,
* direct assignment in tests).
*
* @module extension-logic
*/
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import type { TUIMessage, PendingAsk, ToolData } from "../types.js"
import {
extractToolData,
formatToolOutput,
formatToolAskMessage,
parseTodosFromToolInfo,
} from "../../lib/utils/tools.js"
import { getGlobalCommand } from "../../lib/utils/commands.js"
// ================================================================
// Context types (read-only snapshots passed into pure functions)
// ================================================================
/** Read-only snapshot of tracking state needed by message processors. */
export interface MessageContext {
seenMessageIds: ReadonlySet<string>
firstTextMessageSkipped: boolean
isResumingTask: boolean
pendingCommandRef: string | null
nonInteractive: boolean
currentTodos: readonly TodoItem[]
}
/** Minimal store state needed by computeSubmitAction. */
export interface SubmitContext {
pendingAsk: PendingAsk | null
hasStartedTask: boolean
isComplete: boolean
}
// ================================================================
// Result types (returned by pure functions)
// ================================================================
/** Result from {@link processSayMessage}. */
export interface SayMessageResult {
/** Message to add to the store, or null if the message should be skipped. */
message: TUIMessage | null
/** If non-null, add this ID to seenMessageIds. */
trackId: string | null
/** If true, caller should set firstTextMessageSkipped = true. */
setFirstTextSkipped?: boolean
/** If true, caller should set pendingCommandRef = null. */
clearPendingCommand?: boolean
}
/** Result from {@link processAskMessage}. */
export interface AskMessageResult {
/** Message to add to the store, or null if no message should be added. */
message: TUIMessage | null
/** If non-null, set pendingAsk to this value. Null means no change. */
pendingAsk: PendingAsk | null
/** If non-null, add this ID to seenMessageIds. */
trackId: string | null
/** Partial store field updates to apply. Only defined keys should be set. */
storeUpdates: {
isLoading?: boolean
hasStartedTask?: boolean
isResumingTask?: boolean
isComplete?: boolean
}
/** If defined, set pendingCommandRef to this value. Undefined means no change. */
pendingCommand?: string
/** If defined, apply these todo updates to the store. */
todoUpdate?: { currentTodos: TodoItem[]; previousTodos: TodoItem[] }
}
/** Discriminated union describing what handleSubmit should do. */
export type SubmitAction =
| { kind: "none" }
| { kind: "clearTask" }
| { kind: "respondToAsk"; userMessage: TUIMessage; text: string }
| { kind: "startNewTask"; userMessage: TUIMessage; text: string }
| { kind: "continueTask"; userMessage: TUIMessage; text: string }
// ================================================================
// Pure functions
// ================================================================
/** Build a user message with the given ID and content. */
export function buildUserMessage(id: string, content: string): TUIMessage {
return { id, role: "user", content }
}
/**
* Process a "say" type ClineMessage and compute the resulting state change.
*
* The caller is responsible for:
* 1. Adding `result.trackId` to seenMessageIds (if non-null)
* 2. Setting firstTextMessageSkipped if `result.setFirstTextSkipped` is true
* 3. Clearing pendingCommandRef if `result.clearPendingCommand` is true
* 4. Calling addMessage with `result.message` (if non-null)
*/
export function processSayMessage(
ctx: MessageContext,
ts: number,
say: ClineSay,
text: string,
partial: boolean,
): SayMessageResult {
const messageId = ts.toString()
const result: SayMessageResult = {
message: null,
trackId: null,
}
// Skip filtered message types
if (say === "checkpoint_saved" || say === "api_req_started" || say === "user_feedback") {
if (say === "user_feedback") {
result.trackId = messageId
}
return result
}
// Skip first text message (unless resuming a task)
if (say === "text" && !ctx.firstTextMessageSkipped && !ctx.isResumingTask) {
result.trackId = messageId
result.setFirstTextSkipped = true
return result
}
// Dedup: skip already-seen non-partial messages
if (ctx.seenMessageIds.has(messageId) && !partial) {
return result
}
// Build the message
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 = ctx.pendingCommandRef
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
result.clearPendingCommand = true
} else if (say === "reasoning") {
role = "thinking"
}
result.trackId = messageId
result.message = {
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
}
return result
}
/**
* Process an "ask" type ClineMessage and compute the resulting state change.
*
* The caller is responsible for:
* 1. Adding `result.trackId` to seenMessageIds (if non-null)
* 2. Setting pendingCommandRef if `result.pendingCommand` is defined
* 3. Applying `result.storeUpdates` to the store
* 4. Applying `result.todoUpdate` to the store (if defined)
* 5. Setting pendingAsk if `result.pendingAsk` is non-null
* 6. Calling addMessage with `result.message` (if non-null)
*/
export function processAskMessage(
ctx: MessageContext,
ts: number,
ask: ClineAsk,
text: string,
partial: boolean,
): AskMessageResult {
const messageId = ts.toString()
const result: AskMessageResult = {
message: null,
pendingAsk: null,
trackId: null,
storeUpdates: {},
}
if (partial) return result
if (ctx.seenMessageIds.has(messageId)) return result
if (ask === "command_output") {
result.trackId = messageId
return result
}
if (ask === "resume_task" || ask === "resume_completed_task") {
result.trackId = messageId
result.storeUpdates = {
isLoading: false,
hasStartedTask: true,
isResumingTask: false,
}
return result
}
if (ask === "completion_result") {
result.trackId = messageId
result.storeUpdates = { isComplete: true, 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,
}
result.message = {
id: messageId,
role: "tool",
content: text,
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
originalType: ask,
toolData: toolDataVal,
}
} catch {
result.message = {
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 result
}
if (ask === "command") {
result.pendingCommand = text
}
// Non-interactive mode: auto-process asks (except followup)
if (ctx.nonInteractive && ask !== "followup") {
result.trackId = 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)
// Handle todo updates (fixes missing todo handling bug)
if (localToolName === "update_todo_list" || localToolName === "updateTodoList") {
const parsedTodos = parseTodosFromToolInfo(toolInfo)
if (parsedTodos && parsedTodos.length > 0) {
todos = parsedTodos
previousTodos = [...ctx.currentTodos]
result.todoUpdate = {
currentTodos: parsedTodos,
previousTodos: [...ctx.currentTodos],
}
}
}
} catch {
// Use raw text
}
result.message = {
id: messageId,
role: "tool",
content: formattedContent,
toolName: localToolName,
toolDisplayName: localToolDisplayName,
toolDisplayOutput: localToolDisplayOutput,
originalType: ask,
toolData: localToolData,
todos,
previousTodos,
}
} else {
result.message = {
id: messageId,
role: "assistant",
content: text || "",
originalType: ask,
}
}
return result
}
// Interactive mode: set up pendingAsk
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
}
}
result.trackId = messageId
result.pendingAsk = {
id: messageId,
type: ask,
content: questionText,
suggestions,
}
return result
}
/**
* Compute what action a submit should take based on current state.
*
* The caller is responsible for:
* - "none": do nothing
* - "clearTask": reset all store state, clear seenMessageIds, send clearTask/requestCommands/requestModes
* - "respondToAsk": add userMessage, send askResponse, clear pendingAsk, set isLoading
* - "startNewTask": set hasStartedTask/isLoading, add userMessage, call runTask
* - "continueTask": clear isComplete if needed, set isLoading, add userMessage, send askResponse
*/
export function computeSubmitAction(ctx: SubmitContext, text: string, generateId: () => string): SubmitAction {
if (!text.trim()) return { kind: "none" }
const trimmedText = text.trim()
if (trimmedText === "__CUSTOM__") return { kind: "none" }
// 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") {
return { kind: "clearTask" }
}
}
}
const userMessage = buildUserMessage(generateId(), trimmedText)
if (ctx.pendingAsk) {
return { kind: "respondToAsk", userMessage, text: trimmedText }
}
if (!ctx.hasStartedTask) {
return { kind: "startNewTask", userMessage, text: trimmedText }
}
return { kind: "continueTask", userMessage, text: trimmedText }
}

View file

@ -9,36 +9,14 @@ 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 type { ClineMessage, 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 type { TUIMessage, PendingAsk, FileResult, SlashCommandResult, ModeResult, TaskHistoryItem } from "../types.js"
import { createSimpleContext } from "./helper.js"
import { processSayMessage, processAskMessage, computeSubmitAction, type MessageContext } from "./extension-logic.js"
/** Streaming message debounce configuration. */
const STREAMING_DEBOUNCE_MS = 150
@ -113,9 +91,21 @@ export const { use: useExtension, provider: ExtensionProvider } = createSimpleCo
let streamingDebounceTimer: ReturnType<typeof setTimeout> | null = null
// ================================================================
// Message handling (ported from useMessageHandlers)
// Message handling (delegates to pure functions in extension-logic)
// ================================================================
/** Build a MessageContext snapshot for pure functions. */
function getMessageContext(): MessageContext {
return {
seenMessageIds,
firstTextMessageSkipped,
isResumingTask: store.isResumingTask,
pendingCommandRef,
nonInteractive: props.options.nonInteractive ?? false,
currentTodos: store.currentTodos,
}
}
function addMessage(msg: TUIMessage) {
const existingIndex = store.messages.findIndex((m) => m.id === msg.id)
@ -166,203 +156,45 @@ export const { use: useExtension, provider: ExtensionProvider } = createSimpleCo
)
}
function handleSayMessage(ts: number, say: ClineSay, text: string, partial: boolean) {
const messageId = ts.toString()
function handleSayMessage(
ts: number,
say: Parameters<typeof processSayMessage>[2],
text: string,
partial: boolean,
) {
const result = processSayMessage(getMessageContext(), ts, say, text, partial)
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,
})
if (result.trackId) seenMessageIds.add(result.trackId)
if (result.setFirstTextSkipped) firstTextMessageSkipped = true
if (result.clearPendingCommand) pendingCommandRef = null
if (result.message) addMessage(result.message)
}
function handleAskMessage(ts: number, ask: ClineAsk, text: string, partial: boolean) {
const messageId = ts.toString()
function handleAskMessage(
ts: number,
ask: Parameters<typeof processAskMessage>[2],
text: string,
partial: boolean,
) {
const result = processAskMessage(getMessageContext(), ts, ask, text, partial)
if (partial) return
if (seenMessageIds.has(messageId)) return
if (ask === "command_output") {
seenMessageIds.add(messageId)
return
}
if (result.trackId) seenMessageIds.add(result.trackId)
if (result.pendingCommand !== undefined) pendingCommandRef = result.pendingCommand
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 },
})
batch(() => {
const u = result.storeUpdates
if (u.isLoading !== undefined) setStore("isLoading", u.isLoading)
if (u.hasStartedTask !== undefined) setStore("hasStartedTask", u.hasStartedTask)
if (u.isResumingTask !== undefined) setStore("isResumingTask", u.isResumingTask)
if (u.isComplete !== undefined) setStore("isComplete", u.isComplete)
if (result.todoUpdate) {
setStore("previousTodos", result.todoUpdate.previousTodos)
setStore("currentTodos", result.todoUpdate.currentTodos)
}
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,
if (result.pendingAsk) setStore("pendingAsk", result.pendingAsk)
})
if (result.message) addMessage(result.message)
}
function handleExtensionMessage(msg: ExtensionMessage) {
@ -432,74 +264,81 @@ export const { use: useExtension, provider: ExtensionProvider } = createSimpleCo
}
async function handleSubmit(text: string) {
if (!host || !text.trim()) return
if (!host) return
const trimmedText = text.trim()
if (trimmedText === "__CUSTOM__") return
const action = computeSubmitAction(
{
pendingAsk: store.pendingAsk,
hasStartedTask: store.hasStartedTask,
isComplete: store.isComplete,
},
text,
() => randomUUID(),
)
// 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
}
}
}
switch (action.kind) {
case "none":
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) {
case "clearTask":
batch(() => {
setStore("error", err instanceof Error ? err.message : String(err))
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", [])
})
}
} else {
if (store.isComplete) setStore("isComplete", false)
setStore("isLoading", true)
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
seenMessageIds.clear()
firstTextMessageSkipped = false
sendToExtension({ type: "clearTask" })
sendToExtension({ type: "requestCommands" })
sendToExtension({ type: "requestModes" })
return
case "respondToAsk":
addMessage(action.userMessage)
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: action.text,
})
batch(() => {
setStore("pendingAsk", null)
setStore("isLoading", true)
})
return
case "startNewTask":
batch(() => {
setStore("hasStartedTask", true)
setStore("isLoading", true)
})
addMessage(action.userMessage)
try {
await runTask(action.text)
} catch (err) {
batch(() => {
setStore("error", err instanceof Error ? err.message : String(err))
setStore("isLoading", false)
})
}
return
case "continueTask":
if (store.isComplete) setStore("isComplete", false)
setStore("isLoading", true)
addMessage(action.userMessage)
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: action.text,
})
return
}
}

View file

@ -3,7 +3,7 @@
* approval prompts, and text input.
*/
import { For, Show, Switch, Match, createMemo, createSignal } from "solid-js"
import { For, Show, Switch, Match, createSignal } from "solid-js"
import { useTerminalDimensions, useKeyboard } from "@opentui/solid"
import { type KeyEvent } from "@opentui/core"
import { useTheme } from "../../context/theme.js"
@ -181,7 +181,7 @@ function FollowupPrompt(props: { content: string; suggestions?: Array<{ answer:
}
export function Session(props: SessionProps) {
const { theme } = useTheme()
const { theme: _theme } = useTheme()
const ext = useExtension()
const dims = useTerminalDimensions()

View file

@ -11,22 +11,26 @@ describe("copyToClipboard", () => {
beforeEach(() => {
originalWrite = process.stdout.write
writeSpy = vi.fn()
process.stdout.write = writeSpy as any
process.stdout.write = writeSpy as typeof process.stdout.write
})
afterEach(() => {
process.stdout.write = originalWrite
})
const ESC = String.fromCharCode(0x1b)
const BEL = String.fromCharCode(0x07)
const OSC52_RE = new RegExp(`^${ESC}\\]52;c;(.*)${BEL}$`)
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$/)
expect(output).toMatch(OSC52_RE)
// Verify base64 encoding
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
expect(base64Match).toBeTruthy()
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe("hello")
@ -37,7 +41,7 @@ describe("copyToClipboard", () => {
expect(writeSpy).toHaveBeenCalledTimes(1)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe("")
})
@ -47,7 +51,7 @@ describe("copyToClipboard", () => {
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
@ -57,7 +61,7 @@ describe("copyToClipboard", () => {
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
@ -67,7 +71,7 @@ describe("copyToClipboard", () => {
copyToClipboard(text)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(text)
})
@ -77,7 +81,7 @@ describe("copyToClipboard", () => {
copyToClipboard(largeText)
const output = writeSpy.mock.calls[0]![0] as string
const base64Match = output.match(/^\x1b\]52;c;(.*)\x07$/)
const base64Match = output.match(OSC52_RE)
const base64 = base64Match![1]!
expect(Buffer.from(base64, "base64").toString("utf-8")).toBe(largeText)
})
@ -90,7 +94,7 @@ describe("clearClipboard", () => {
beforeEach(() => {
originalWrite = process.stdout.write
writeSpy = vi.fn()
process.stdout.write = writeSpy as any
process.stdout.write = writeSpy as typeof process.stdout.write
})
afterEach(() => {

View file

@ -18,10 +18,10 @@ describe("getTerminalBackgroundColor", () => {
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
process.stdin.setRawMode = mockSetRawMode as typeof process.stdin.setRawMode
process.stdin.on = mockOn as typeof process.stdin.on
process.stdin.removeListener = mockRemoveListener as typeof process.stdin.removeListener
process.stdout.write = mockStdoutWrite as typeof process.stdout.write
})
afterEach(() => {

View file

@ -9,20 +9,19 @@
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
const ESC = String.fromCharCode(0x1b)
const BEL = String.fromCharCode(0x07)
const OSC11_PATTERN = new RegExp(`${ESC}]11;([^${BEL}${ESC}]+)`)
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]+)/)
const match = str.match(OSC11_PATTERN)
if (match) {
cleanup()
clearTimeout(timeout)
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
const color = match[1]!
let r = 0,
g = 0,
@ -46,10 +45,11 @@ export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
process.stdout.write(`${ESC}]11;?${BEL}`)
timeout = setTimeout(() => {
cleanup()
const timeout = setTimeout(() => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
resolve("dark")
}, 1000)
})

View file

@ -1,617 +0,0 @@
import { Box, Text, useApp, useInput } from "ink"
import { Select } from "@inkjs/ui"
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
import { getGlobalCommandsForAutocomplete } from "@/lib/utils/commands.js"
import { arePathsEqual } from "@/lib/utils/path.js"
import { getContextWindow } from "@/lib/utils/context-window.js"
import * as theme from "./theme.js"
import { useCLIStore } from "./store.js"
import { useUIStateStore } from "./stores/uiStateStore.js"
// Import extracted hooks.
import {
TerminalSizeProvider,
useTerminalSize,
useToast,
useExtensionHost,
useMessageHandlers,
useTaskSubmit,
useGlobalInput,
useFollowupCountdown,
useFocusManagement,
usePickerHandlers,
} from "./hooks/index.js"
// Import extracted utilities.
import { getView } from "./utils/index.js"
// Import components.
import Header from "./components/Header.js"
import ChatHistoryItem from "./components/ChatHistoryItem.js"
import LoadingText from "./components/LoadingText.js"
import ToastDisplay from "./components/ToastDisplay.js"
import TodoDisplay from "./components/TodoDisplay.js"
import { HorizontalLine } from "./components/HorizontalLine.js"
import {
type AutocompleteInputHandle,
type AutocompleteTrigger,
type FileResult,
type SlashCommandResult,
AutocompleteInput,
PickerSelect,
createFileTrigger,
createSlashCommandTrigger,
createModeTrigger,
createHelpTrigger,
createHistoryTrigger,
toFileResult,
toSlashCommandResult,
toModeResult,
toHistoryResult,
} from "./components/autocomplete/index.js"
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
import ScrollIndicator from "./components/ScrollIndicator.js"
const PICKER_HEIGHT = 10
export interface TUIAppProps extends ExtensionHostOptions {
initialPrompt?: string
version: string
// Create extension host factory for dependency injection.
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
/**
* Inner App component that uses the terminal size context
*/
function AppInner({ createExtensionHost, ...extensionHostOptions }: TUIAppProps) {
const {
initialPrompt,
workspacePath,
extensionPath,
user,
provider,
apiKey,
model,
mode,
nonInteractive = false,
debug,
exitOnComplete,
reasoningEffort,
ephemeral,
version,
} = extensionHostOptions
const { exit } = useApp()
const {
messages,
pendingAsk,
isLoading,
isComplete,
hasStartedTask: _hasStartedTask,
error,
fileSearchResults,
allSlashCommands,
availableModes,
taskHistory,
currentMode,
tokenUsage,
routerModels,
apiConfiguration,
currentTodos,
} = useCLIStore()
// Access UI state from the UI store
const {
showExitHint,
countdownSeconds,
showCustomInput,
isTransitioningToCustomInput,
showTodoViewer,
pickerState,
setIsTransitioningToCustomInput,
} = useUIStateStore()
// Compute context window from router models and API configuration
const contextWindow = useMemo(() => {
return getContextWindow(routerModels, apiConfiguration)
}, [routerModels, apiConfiguration])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
// Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change
const fileSearchResultsRef = useRef(fileSearchResults)
const allSlashCommandsRef = useRef(allSlashCommands)
const availableModesRef = useRef(availableModes)
const taskHistoryRef = useRef(taskHistory)
// Keep refs in sync with current state
useEffect(() => {
fileSearchResultsRef.current = fileSearchResults
}, [fileSearchResults])
useEffect(() => {
allSlashCommandsRef.current = allSlashCommands
}, [allSlashCommands])
useEffect(() => {
availableModesRef.current = availableModes
}, [availableModes])
useEffect(() => {
taskHistoryRef.current = taskHistory
}, [taskHistory])
// Scroll area state
const { rows } = useTerminalSize()
const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick).
const rafIdRef = useRef<NodeJS.Immediate | null>(null)
const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null)
// Toast notifications for ephemeral messages (e.g., mode changes).
const { currentToast, showInfo } = useToast()
const {
handleExtensionMessage,
seenMessageIds,
pendingCommandRef: _pendingCommandRef,
firstTextMessageSkipped,
} = useMessageHandlers({
nonInteractive,
})
const { sendToExtension, runTask, cleanup } = useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
user,
provider,
apiKey,
model,
workspacePath,
extensionPath,
debug,
nonInteractive,
ephemeral,
exitOnComplete,
onExtensionMessage: handleExtensionMessage,
createExtensionHost,
})
// Initialize task submit hook
const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
sendToExtension,
runTask,
seenMessageIds,
firstTextMessageSkipped,
})
// Initialize focus management hook
const { canToggleFocus, isScrollAreaActive, isInputAreaActive, toggleFocus } = useFocusManagement({
showApprovalPrompt: Boolean(pendingAsk && pendingAsk.type !== "followup"),
pendingAsk,
})
// Initialize countdown hook for followup auto-accept
const { cancelCountdown } = useFollowupCountdown({
pendingAsk,
onAutoSubmit: handleSubmit,
})
// Initialize picker handlers hook
const { handlePickerStateChange, handlePickerSelect, handlePickerClose, handlePickerIndexChange } =
usePickerHandlers({
autocompleteRef,
followupAutocompleteRef,
sendToExtension,
showInfo,
seenMessageIds,
firstTextMessageSkipped,
})
// Initialize global input hook
useGlobalInput({
canToggleFocus,
isScrollAreaActive,
pickerIsOpen: pickerState.isOpen,
availableModes,
currentMode,
mode,
sendToExtension,
showInfo,
exit,
cleanup,
toggleFocus,
closePicker: handlePickerClose,
})
// Determine current view
const view = getView(messages, pendingAsk, isLoading)
// Determine if we should show the approval prompt (Y/N) instead of text input
const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup"
// Display all messages including partial (streaming) ones
const displayMessages = useMemo(() => {
return messages
}, [messages])
// Scroll to bottom when new messages arrive (if auto-scroll is enabled)
const prevMessageCount = useRef(messages.length)
useEffect(() => {
if (messages.length > prevMessageCount.current && scrollState.isAtBottom) {
scrollToBottom()
}
prevMessageCount.current = messages.length
}, [messages.length, scrollState.isAtBottom, scrollToBottom])
// Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates)
const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
// Store the latest scroll values in ref
pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom }
// Only schedule one update per event loop tick
if (rafIdRef.current === null) {
rafIdRef.current = setImmediate(() => {
rafIdRef.current = null
const pending = pendingScrollRef.current
if (pending) {
setScrollState(pending)
pendingScrollRef.current = null
}
})
}
}, [])
// Cleanup RAF-style timer on unmount
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
clearImmediate(rafIdRef.current)
}
}
}, [])
// File search handler for the file trigger
const handleFileSearch = useCallback(
(query: string) => {
if (!sendToExtension) {
return
}
sendToExtension({ type: "searchFiles", query })
},
[sendToExtension],
)
// Create autocomplete triggers
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult)
// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
// The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
const fileTrigger = createFileTrigger({
onSearch: handleFileSearch,
getResults: () => {
const results = fileSearchResultsRef.current
return results.map(toFileResult)
},
})
const slashCommandTrigger = createSlashCommandTrigger({
getCommands: () => {
// Merge CLI global commands with extension commands
const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult)
const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult)
// Global commands appear first, then extension commands
return [...globalCommands, ...extensionCommands]
},
})
const modeTrigger = createModeTrigger({
getModes: () => availableModesRef.current.map(toModeResult),
})
const helpTrigger = createHelpTrigger()
// History trigger - type # to search and resume previous tasks
const historyTrigger = createHistoryTrigger({
getHistory: () => {
// Filter to only show tasks for the current workspace
// Use arePathsEqual for proper cross-platform path comparison
// (handles trailing slashes, separators, and case sensitivity)
const history = taskHistoryRef.current
const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath))
return filtered.map(toHistoryResult)
},
})
return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger]
}, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs
// Refresh search results when fileSearchResults changes while file picker is open
// This handles the async timing where API results arrive after initial search
// IMPORTANT: Only run when fileSearchResults array identity changes (new API response)
// We use a ref to track this and avoid depending on pickerState in the effect
const prevFileSearchResultsRef = useRef(fileSearchResults)
const pickerStateRef = useRef(pickerState)
pickerStateRef.current = pickerState
useEffect(() => {
// Only run if fileSearchResults actually changed (different array reference)
if (fileSearchResults === prevFileSearchResultsRef.current) {
return
}
const currentPickerState = pickerStateRef.current
const willRefresh =
currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0
prevFileSearchResultsRef.current = fileSearchResults
// Only refresh when file picker is open and we have new results
if (willRefresh) {
autocompleteRef.current?.refreshSearch()
followupAutocompleteRef.current?.refreshSearch()
}
}, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref
// Handle Y/N input for approval prompts
useInput((input) => {
if (pendingAsk && pendingAsk.type !== "followup") {
const lower = input.toLowerCase()
if (lower === "y") {
handleApprove()
} else if (lower === "n") {
handleReject()
}
}
})
// Cancel countdown timer when user navigates in the followup suggestion menu
// This provides better UX - any user interaction cancels the auto-accept timer
const showFollowupSuggestions =
pendingAsk?.type === "followup" &&
pendingAsk.suggestions &&
pendingAsk.suggestions.length > 0 &&
!showCustomInput
useInput((_input, key) => {
// Only handle when followup suggestions are shown and countdown is active
if (showFollowupSuggestions && countdownSeconds !== null) {
// Cancel countdown on any arrow key navigation
if (key.upArrow || key.downArrow) {
cancelCountdown()
}
}
})
// Error display
if (error) {
return (
<Box flexDirection="column" padding={1}>
<Text color="red" bold>
Error: {error}
</Text>
<Text color="gray" dimColor>
Press Ctrl+C to exit
</Text>
</Box>
)
}
// Status bar content
// Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint
// Don't show spinner when waiting for user input (pendingAsk is set)
const statusBarMessage = currentToast ? (
<ToastDisplay toast={currentToast} />
) : showExitHint ? (
<Text color="yellow">Press Ctrl+C again to exit</Text>
) : isLoading && !pendingAsk ? (
<Box>
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
<Text color={theme.dimText}> </Text>
<Text color={theme.dimText}>Esc to cancel</Text>
{isScrollAreaActive && (
<>
<Text color={theme.dimText}> </Text>
<ScrollIndicator
scrollTop={scrollState.scrollTop}
maxScroll={scrollState.maxScroll}
isScrollFocused={true}
/>
</>
)}
</Box>
) : isScrollAreaActive ? (
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
) : isInputAreaActive ? (
<Text color={theme.dimText}>? for shortcuts</Text>
) : null
const getPickerRenderItem = () => {
if (pickerState.activeTrigger) {
return pickerState.activeTrigger.renderItem
}
return (item: FileResult | SlashCommandResult, isSelected: boolean) => (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
</Box>
)
}
return (
<Box flexDirection="column" height={rows - 1}>
{/* Header - fixed size */}
<Box flexShrink={0}>
<Header
{...extensionHostOptions}
mode={currentMode || mode}
version={version}
tokenUsage={tokenUsage}
contextWindow={contextWindow}
/>
</Box>
{/* Scrollable message history area - fills remaining space via flexGrow */}
<ScrollArea
isActive={isScrollAreaActive}
onScroll={handleScroll}
scrollToBottomTrigger={scrollToBottomTrigger}>
{displayMessages.map((message) => (
<ChatHistoryItem key={message.id} message={message} />
))}
</ScrollArea>
{/* Input area - with borders like Claude Code - fixed size */}
<Box flexDirection="column" flexShrink={0}>
{pendingAsk?.type === "followup" ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={true} />
<Select
options={[
...pendingAsk.suggestions.map((s) => ({
label: s.answer,
value: s.answer,
})),
{ label: "Type something...", value: "__CUSTOM__" },
]}
onChange={(value) => {
if (!value || typeof value !== "string") return
if (showCustomInput || isTransitioningToCustomInput) return
if (value === "__CUSTOM__") {
// Clear countdown timer and switch to custom input
cancelCountdown()
setIsTransitioningToCustomInput(true)
useUIStateStore.getState().setShowCustomInput(true)
} else if (value.trim()) {
handleSubmit(value)
}
}}
/>
<HorizontalLine active={true} />
<Text color={theme.dimText}>
navigate Enter select
{countdownSeconds !== null && (
<Text color="yellow"> Auto-select in {countdownSeconds}s</Text>
)}
</Text>
</Box>
) : (
<Box flexDirection="column" marginTop={1}>
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={followupAutocompleteRef}
placeholder="Type your response..."
onSubmit={(text: string) => {
if (text && text.trim()) {
handleSubmit(text)
useUIStateStore.getState().setShowCustomInput(false)
setIsTransitioningToCustomInput(false)
}
}}
isActive={true}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt="> "
/>
<HorizontalLine active={isInputAreaActive} />
{pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
isLoading={pickerState.isLoading}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
) : showApprovalPrompt ? (
<Box flexDirection="column">
<Text color={theme.rooHeader}>{pendingAsk?.content}</Text>
<Text color={theme.dimText}>
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
<Text color={theme.errorColor}>N</Text> to reject
</Text>
<Box height={1}>{statusBarMessage}</Box>
</Box>
) : (
<Box flexDirection="column">
<HorizontalLine active={isInputAreaActive} />
<AutocompleteInput
ref={autocompleteRef}
placeholder={isComplete ? "Type to continue..." : ""}
onSubmit={handleSubmit}
isActive={isInputAreaActive}
triggers={autocompleteTriggers}
onPickerStateChange={handlePickerStateChange}
prompt=" "
/>
<HorizontalLine active={isInputAreaActive} />
{showTodoViewer ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<TodoDisplay todos={currentTodos} showProgress={true} title="TODO List" />
<Box height={1}>
<Text color={theme.dimText}>Ctrl+T to close</Text>
</Box>
</Box>
) : pickerState.isOpen ? (
<Box flexDirection="column" height={PICKER_HEIGHT}>
<PickerSelect
results={pickerState.results}
selectedIndex={pickerState.selectedIndex}
maxVisible={PICKER_HEIGHT - 1}
onSelect={handlePickerSelect}
onEscape={handlePickerClose}
onIndexChange={handlePickerIndexChange}
renderItem={getPickerRenderItem()}
emptyMessage={pickerState.activeTrigger?.emptyMessage}
isActive={isInputAreaActive && pickerState.isOpen}
isLoading={pickerState.isLoading}
/>
</Box>
) : (
<Box height={1}>{statusBarMessage}</Box>
)}
</Box>
)}
</Box>
</Box>
)
}
/**
* Main TUI Application Component - wraps with TerminalSizeProvider
*/
export function App(props: TUIAppProps) {
return (
<TerminalSizeProvider>
<AppInner {...props} />
</TerminalSizeProvider>
)
}

View file

@ -1,279 +0,0 @@
import { RooCodeSettings } from "@roo-code/types"
import { useCLIStore } from "../store.js"
describe("useCLIStore", () => {
beforeEach(() => {
// Reset store to initial state before each test
useCLIStore.getState().reset()
})
describe("initialState", () => {
it("should have isResumingTask set to false initially", () => {
const state = useCLIStore.getState()
expect(state.isResumingTask).toBe(false)
})
it("should have empty messages array initially", () => {
const state = useCLIStore.getState()
expect(state.messages).toEqual([])
})
it("should have empty taskHistory initially", () => {
const state = useCLIStore.getState()
expect(state.taskHistory).toEqual([])
})
})
describe("setIsResumingTask", () => {
it("should set isResumingTask to true", () => {
useCLIStore.getState().setIsResumingTask(true)
expect(useCLIStore.getState().isResumingTask).toBe(true)
})
it("should set isResumingTask to false", () => {
useCLIStore.getState().setIsResumingTask(true)
useCLIStore.getState().setIsResumingTask(false)
expect(useCLIStore.getState().isResumingTask).toBe(false)
})
})
describe("reset", () => {
it("should reset all state to initial values", () => {
// Set some state first
const store = useCLIStore.getState()
store.addMessage({ id: "1", role: "user", content: "test" })
store.setTaskHistory([{ id: "task1", task: "test", workspace: "/test", ts: Date.now() }])
store.setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
store.setAllSlashCommands([{ key: "test", name: "test", source: "global" as const }])
store.setIsResumingTask(true)
store.setLoading(true)
store.setHasStartedTask(true)
// Reset
useCLIStore.getState().reset()
// Verify all state is reset
const resetState = useCLIStore.getState()
expect(resetState.messages).toEqual([])
expect(resetState.taskHistory).toEqual([])
expect(resetState.availableModes).toEqual([])
expect(resetState.allSlashCommands).toEqual([])
expect(resetState.isResumingTask).toBe(false)
expect(resetState.isLoading).toBe(false)
expect(resetState.hasStartedTask).toBe(false)
})
})
describe("resetForTaskSwitch", () => {
it("should clear task-specific state", () => {
// Set up task-specific state
const store = useCLIStore.getState()
store.addMessage({ id: "1", role: "user", content: "test" })
store.setLoading(true)
store.setComplete(true)
store.setHasStartedTask(true)
store.setError("some error")
store.setIsResumingTask(true)
store.setTokenUsage({
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 0,
totalCacheReads: 0,
totalCacheWrites: 0,
})
store.setTodos([{ id: "1", content: "test todo", status: "pending" }])
// Reset for task switch
useCLIStore.getState().resetForTaskSwitch()
// Verify task-specific state is cleared
const resetState = useCLIStore.getState()
expect(resetState.messages).toEqual([])
expect(resetState.pendingAsk).toBeNull()
expect(resetState.isLoading).toBe(false)
expect(resetState.isComplete).toBe(false)
expect(resetState.hasStartedTask).toBe(false)
expect(resetState.error).toBeNull()
expect(resetState.isResumingTask).toBe(false)
expect(resetState.tokenUsage).toBeNull()
expect(resetState.currentTodos).toEqual([])
expect(resetState.previousTodos).toEqual([])
})
it("should PRESERVE taskHistory", () => {
const taskHistory = [
{ id: "task1", task: "test task 1", workspace: "/test", ts: Date.now() },
{ id: "task2", task: "test task 2", workspace: "/test", ts: Date.now() },
]
useCLIStore.getState().setTaskHistory(taskHistory)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().taskHistory).toEqual(taskHistory)
})
it("should PRESERVE availableModes", () => {
const modes = [
{ key: "code", slug: "code", name: "Code", description: "Code mode" },
{ key: "architect", slug: "architect", name: "Architect", description: "Architect mode" },
]
useCLIStore.getState().setAvailableModes(modes)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().availableModes).toEqual(modes)
})
it("should PRESERVE allSlashCommands", () => {
const commands = [
{ key: "new", name: "new", description: "New task", source: "global" as const },
{ key: "help", name: "help", description: "Get help", source: "built-in" as const },
]
useCLIStore.getState().setAllSlashCommands(commands)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().allSlashCommands).toEqual(commands)
})
it("should PRESERVE fileSearchResults", () => {
const results = [
{ key: "file1", path: "file1.ts", type: "file" as const },
{ key: "file2", path: "file2.ts", type: "file" as const },
]
useCLIStore.getState().setFileSearchResults(results)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().fileSearchResults).toEqual(results)
})
it("should PRESERVE currentMode", () => {
useCLIStore.getState().setCurrentMode("architect")
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().currentMode).toBe("architect")
})
it("should PRESERVE routerModels", () => {
const models = { openai: { "gpt-4": { contextWindow: 128000 } } }
useCLIStore.getState().setRouterModels(models)
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().routerModels).toEqual(models)
})
it("should PRESERVE apiConfiguration", () => {
const config: RooCodeSettings = { apiProvider: "openai", apiModelId: "gpt-4" }
useCLIStore
.getState()
.setApiConfiguration(config as ReturnType<typeof useCLIStore.getState>["apiConfiguration"])
useCLIStore.getState().resetForTaskSwitch()
expect(useCLIStore.getState().apiConfiguration).toEqual(config)
})
})
describe("task resumption flow", () => {
it("should support the full task resumption workflow", () => {
const store = useCLIStore.getState
// Step 1: Initial state with task history and modes from webviewDidLaunch.
store().setTaskHistory([{ id: "task1", task: "Previous task", workspace: "/test", ts: Date.now() }])
store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
store().setAllSlashCommands([{ key: "new", name: "new", source: "global" as const }])
// Step 2: User starts a new task.
store().setHasStartedTask(true)
store().addMessage({ id: "1", role: "user", content: "New task" })
store().addMessage({ id: "2", role: "assistant", content: "Working on it..." })
store().setLoading(true)
// Verify current state.
expect(store().messages.length).toBe(2)
expect(store().hasStartedTask).toBe(true)
// Step 3: User selects a task from history to resume.
// This triggers resetForTaskSwitch + setIsResumingTask(true).
store().resetForTaskSwitch()
store().setIsResumingTask(true)
// Verify task-specific state is cleared but global state preserved.
expect(store().messages).toEqual([])
expect(store().isLoading).toBe(false)
expect(store().hasStartedTask).toBe(false)
expect(store().isResumingTask).toBe(true) // Flag is set.
expect(store().taskHistory.length).toBe(1) // Preserved.
expect(store().availableModes.length).toBe(1) // Preserved.
expect(store().allSlashCommands.length).toBe(1) // Preserved.
// Step 4: Extension sends state message with clineMessages
// (simulated by adding messages).
store().addMessage({ id: "old1", role: "user", content: "Previous task prompt" })
store().addMessage({ id: "old2", role: "assistant", content: "Previous response" })
// Step 5: After processing state, isResumingTask should be cleared.
store().setIsResumingTask(false)
// Final verification.
expect(store().isResumingTask).toBe(false)
expect(store().messages.length).toBe(2)
expect(store().taskHistory.length).toBe(1) // Still preserved.
})
it("should allow reading isResumingTask synchronously during message processing", () => {
const store = useCLIStore.getState
// Set the flag
store().setIsResumingTask(true)
// Simulate synchronous read during message processing
const isResuming = store().isResumingTask
expect(isResuming).toBe(true)
// The handler can use this to decide whether to skip messages
if (!isResuming) {
// Would skip first text message for new tasks
} else {
// Would NOT skip first text message for resumed tasks
}
// After processing, clear the flag
store().setIsResumingTask(false)
expect(store().isResumingTask).toBe(false)
})
})
describe("difference between reset and resetForTaskSwitch", () => {
it("should show that reset clears everything while resetForTaskSwitch preserves global state", () => {
const store = useCLIStore.getState
// Set up both task-specific and global state
store().addMessage({ id: "1", role: "user", content: "test" })
store().setTaskHistory([{ id: "t1", task: "task", workspace: "/", ts: Date.now() }])
store().setAvailableModes([{ key: "code", slug: "code", name: "Code" }])
// Use resetForTaskSwitch
store().resetForTaskSwitch()
// Task-specific cleared, global preserved
expect(store().messages).toEqual([])
expect(store().taskHistory.length).toBe(1)
expect(store().availableModes.length).toBe(1)
// Now use reset()
store().reset()
// Everything cleared
expect(store().messages).toEqual([])
expect(store().taskHistory).toEqual([])
expect(store().availableModes).toEqual([])
})
})
})

View file

@ -1,252 +0,0 @@
import { memo } from "react"
import { Box, Newline, Text } from "ink"
import type { TUIMessage } from "../types.js"
import * as theme from "../theme.js"
import TodoDisplay from "./TodoDisplay.js"
import { getToolRenderer } from "./tools/index.js"
/**
* Tool categories for styling
*/
type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other"
function getToolCategory(toolName: string): ToolCategory {
const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"]
const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"]
const searchTools = ["searchFiles", "search_files"]
const commandTools = ["executeCommand", "execute_command"]
const browserTools = ["browserAction", "browser_action"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"]
const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"]
if (fileTools.includes(toolName)) return "file"
if (dirTools.includes(toolName)) return "directory"
if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion"
return "other"
}
/**
* Category colors for tool types
*/
const CATEGORY_COLORS: Record<ToolCategory, string> = {
file: theme.toolHeader,
directory: theme.toolHeader,
search: theme.warningColor,
command: theme.successColor,
browser: theme.focusColor,
mode: theme.userHeader,
completion: theme.successColor,
other: theme.toolHeader,
}
/**
* Sanitize content for terminal display by:
* - Replacing tab characters with spaces (tabs expand to variable widths in terminals)
* - Stripping carriage returns that could cause display issues
*/
function sanitizeContent(text: string): string {
return text.replace(/\t/g, " ").replace(/\r/g, "")
}
/**
* Truncate content for display, showing line count
*/
function truncateContent(
content: string,
maxLines: number = 10,
): { text: string; truncated: boolean; totalLines: number } {
const lines = content.split("\n")
const totalLines = lines.length
if (lines.length <= maxLines) {
return { text: content, truncated: false, totalLines }
}
const truncatedText = lines.slice(0, maxLines).join("\n")
return { text: truncatedText, truncated: true, totalLines }
}
/**
* Parse tool info from raw JSON content
*/
function parseToolInfo(content: string): Record<string, unknown> | null {
try {
return JSON.parse(content)
} catch {
return null
}
}
/**
* Render tool display component
*/
function ToolDisplay({ message }: { message: TUIMessage }) {
const toolName = message.toolName || "unknown"
const category = getToolCategory(toolName)
const categoryColor = CATEGORY_COLORS[category]
// Try to parse the raw content for additional tool info
const toolInfo = parseToolInfo(message.content || "")
// Extract key fields from tool info
const path = toolInfo?.path as string | undefined
const isOutsideWorkspace = toolInfo?.isOutsideWorkspace as boolean | undefined
const reason = toolInfo?.reason as string | undefined
const rawContent = toolInfo?.content as string | undefined
// Get the display output (formatted by App.tsx) - already sanitized
const toolDisplayOutput = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : undefined
// Sanitize raw content if present
const sanitizedRawContent = rawContent ? sanitizeContent(rawContent) : undefined
// Format the header
const headerText = message.toolDisplayName || toolName
return (
<Box flexDirection="column" paddingX={1}>
{/* Tool Header */}
<Text bold color={categoryColor}>
{headerText}
</Text>
{/* Path indicator for file/directory operations */}
{path && (
<Box marginLeft={2}>
<Text color={theme.dimText}>
{category === "file" ? "file: " : category === "directory" ? "dir: " : "path: "}
</Text>
<Text color={theme.text} bold>
{path}
</Text>
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" (outside workspace)"}
</Text>
)}
</Box>
)}
{/* Reason/explanation if present */}
{reason && (
<Box marginLeft={2}>
<Text color={theme.dimText} italic>
{reason}
</Text>
</Box>
)}
{/* Content display */}
{(toolDisplayOutput || sanitizedRawContent) && (
<Box flexDirection="column" marginLeft={2} marginTop={0}>
{(() => {
const contentToDisplay = toolDisplayOutput || sanitizedRawContent || ""
const { text, truncated, totalLines } = truncateContent(contentToDisplay, 15)
return (
<>
<Text color={theme.toolText}>{text}</Text>
{truncated && (
<Text color={theme.dimText} dimColor>
{`... (${totalLines - 15} more lines)`}
</Text>
)}
</>
)
})()}
</Box>
)}
<Text>
<Newline />
</Text>
</Box>
)
}
interface ChatHistoryItemProps {
message: TUIMessage
}
function ChatHistoryItem({ message }: ChatHistoryItemProps) {
const content = sanitizeContent(message.content || "...")
switch (message.role) {
case "user":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color="magenta">
You said:
</Text>
<Text color={theme.userText}>
{content}
<Newline />
</Text>
</Box>
)
case "assistant":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color="yellow">
Roo said:
</Text>
<Text color={theme.rooText}>
{content}
<Newline />
</Text>
</Box>
)
case "thinking":
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.thinkingHeader} dimColor>
Roo is thinking:
</Text>
<Text color={theme.thinkingText} dimColor>
{content}
<Newline />
</Text>
</Box>
)
case "tool": {
// Special rendering for update_todo_list tool - show full TODO list
if (
(message.toolName === "update_todo_list" || message.toolName === "updateTodoList") &&
message.todos &&
message.todos.length > 0
) {
return <TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
}
// Use the new structured tool renderers when toolData is available
if (message.toolData) {
const ToolRenderer = getToolRenderer(message.toolData.tool)
return <ToolRenderer toolData={message.toolData} rawContent={message.content} />
}
// Fallback to generic ToolDisplay for messages without toolData
return <ToolDisplay message={message} />
}
case "system":
// System messages are typically rendered as Header, not here.
// But if they appear, show them subtly.
return (
<Box flexDirection="column" paddingX={1}>
<Text color="gray" dimColor>
{content}
<Newline />
</Text>
</Box>
)
default:
return null
}
}
export default memo(ChatHistoryItem)

View file

@ -1,74 +0,0 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import { ASCII_ROO } from "@/types/constants.js"
import { ExtensionHostOptions } from "@/agent/index.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../theme.js"
import MetricsDisplay from "./MetricsDisplay.js"
interface HeaderProps extends ExtensionHostOptions {
version: string
tokenUsage?: TokenUsage | null
contextWindow?: number
}
function Header({
workspacePath,
user,
provider,
model,
mode,
reasoningEffort,
nonInteractive,
version,
tokenUsage,
contextWindow,
}: HeaderProps) {
const { columns } = useTerminalSize()
const homeDir = process.env.HOME || process.env.USERPROFILE || ""
const title = `Roo Code CLI v${version}`
const remainingDashes = Math.max(0, columns - `── ${title} `.length)
return (
<Box flexDirection="column" width={columns}>
<Text color={theme.borderColor}>
<Text color={theme.titleColor}>{title}</Text> {"─".repeat(remainingDashes)}
</Text>
<Box width={columns}>
<Box flexDirection="row">
<Box marginY={1}>
<Text color="magenta">{ASCII_ROO}</Text>
</Box>
<Box flexDirection="column" marginLeft={1} marginTop={1}>
{user && <Text color={theme.dimText}>Welcome back, {user.name}</Text>}
<Text color={theme.dimText}>
cwd:{" "}
{workspacePath.startsWith(homeDir) ? workspacePath.replace(homeDir, "~") : workspacePath}
</Text>
<Text color={theme.dimText}>
{provider}: {model} [{reasoningEffort}]
</Text>
<Text color={theme.dimText}>
mode: {mode}
{nonInteractive && " (YOLO)"}
</Text>
</Box>
</Box>
</Box>
{tokenUsage && contextWindow && contextWindow > 0 && (
<Box alignSelf="flex-end" marginTop={-1}>
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
</Box>
)}
<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
</Box>
)
}
export default memo(Header)

View file

@ -1,14 +0,0 @@
import { Text } from "ink"
import * as theme from "../theme.js"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
interface HorizontalLineProps {
active?: boolean
}
export function HorizontalLine({ active = false }: HorizontalLineProps) {
const { columns } = useTerminalSize()
const color = active ? theme.borderColorActive : theme.borderColor
return <Text color={color}>{"─".repeat(columns)}</Text>
}

View file

@ -1,174 +0,0 @@
import { Box, Text } from "ink"
import type { TextProps } from "ink"
/**
* Icon names supported by the Icon component.
* Each icon has a Nerd Font glyph and an ASCII fallback.
*/
export type IconName =
| "folder"
| "file"
| "file-edit"
| "check"
| "cross"
| "arrow-right"
| "bullet"
| "spinner"
// Tool-related icons
| "search"
| "terminal"
| "browser"
| "switch"
| "question"
| "gear"
| "diff"
// TODO-related icons
| "checkbox"
| "checkbox-checked"
| "checkbox-progress"
| "todo-list"
/**
* Icon definitions with Nerd Font glyph and ASCII fallback.
* Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char).
*/
const ICONS: Record<IconName, { nerd: string; fallback: string }> = {
folder: { nerd: "\uf413", fallback: "▼" },
file: { nerd: "\uf4a5", fallback: "●" },
"file-edit": { nerd: "\uf4d2", fallback: "✎" },
check: { nerd: "\uf42e", fallback: "✓" },
cross: { nerd: "\uf517", fallback: "✗" },
"arrow-right": { nerd: "\uf432", fallback: "→" },
bullet: { nerd: "\uf444", fallback: "•" },
spinner: { nerd: "\uf4e3", fallback: "*" },
// Tool-related icons
search: { nerd: "\uf422", fallback: "🔍" },
terminal: { nerd: "\uf489", fallback: "$" },
browser: { nerd: "\uf488", fallback: "🌐" },
switch: { nerd: "\uf443", fallback: "⇄" },
question: { nerd: "\uf420", fallback: "?" },
gear: { nerd: "\uf423", fallback: "⚙" },
diff: { nerd: "\uf4d2", fallback: "±" },
// TODO-related icons
checkbox: { nerd: "\uf4aa", fallback: "○" }, // Empty checkbox
"checkbox-checked": { nerd: "\uf4a4", fallback: "✓" }, // Checked checkbox
"checkbox-progress": { nerd: "\uf4aa", fallback: "→" }, // In progress (dot circle)
"todo-list": { nerd: "\uf45e", fallback: "☑" }, // List icon for TODO header
}
/**
* Check if a string contains surrogate pairs (characters outside BMP).
* Surrogate pairs have .length of 2 but render as 1 visual character.
*/
function containsSurrogatePair(str: string): boolean {
// Surrogate pairs are in the range U+D800 to U+DFFF
return /[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(str)
}
/**
* Detect if Nerd Font icons are likely supported.
*
* Users can override this with the ROOCODE_NERD_FONT environment variable:
* - ROOCODE_NERD_FONT=0 to force ASCII fallbacks (if icons don't render correctly)
* - ROOCODE_NERD_FONT=1 to force Nerd Font icons
*
* Defaults to true because:
* 1. Nerd Fonts are common in developer terminal setups
* 2. Modern terminals handle missing glyphs gracefully
* 3. Users can easily disable if icons don't render correctly
*/
function detectNerdFontSupport(): boolean {
// Allow explicit override via environment variable
const envOverride = process.env.ROOCODE_NERD_FONT
if (envOverride === "0" || envOverride === "false") return false
if (envOverride === "1" || envOverride === "true") return true
// Default to Nerd Font icons - they're common in developer setups
// and users can set ROOCODE_NERD_FONT=0 if needed
return true
}
// Cache the detection result
let nerdFontSupported: boolean | null = null
/**
* Get whether Nerd Font icons are supported (cached).
*/
export function isNerdFontSupported(): boolean {
if (nerdFontSupported === null) {
nerdFontSupported = detectNerdFontSupport()
}
return nerdFontSupported
}
/**
* Reset the Nerd Font detection cache (useful for testing).
*/
export function resetNerdFontCache(): void {
nerdFontSupported = null
}
export interface IconProps extends Omit<TextProps, "children"> {
/** The icon to display */
name: IconName
/** Override the automatic Nerd Font detection */
useNerdFont?: boolean
/** Custom width for the icon container (default: 2) */
width?: number
}
/**
* Icon component that renders Nerd Font icons with ASCII fallbacks.
*
* Renders icons in a fixed-width Box to handle surrogate pair width
* calculation issues in Ink. Surrogate pairs (like Nerd Font glyphs)
* have .length of 2 in JavaScript but render as 1 visual character.
*
* @example
* ```tsx
* <Icon name="folder" color="blue" />
* <Icon name="file" />
* <Icon name="check" color="green" useNerdFont={false} />
* ```
*/
export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: IconProps) {
const iconDef = ICONS[name]
if (!iconDef) {
return null
}
const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
const icon = shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
// Use fixed-width Box to isolate surrogate pair width calculation
// from surrounding text. This prevents the off-by-one truncation bug.
const needsWidthFix = containsSurrogatePair(icon)
if (needsWidthFix) {
return (
<Box width={width}>
<Text color={color} {...textProps}>
{icon}
</Text>
</Box>
)
}
// For BMP characters (no surrogate pairs), render directly
return (
<Text color={color} {...textProps}>
{icon}
</Text>
)
}
/**
* Get the raw icon character (useful for string concatenation).
*/
export function getIconChar(name: IconName, useNerdFont?: boolean): string {
const iconDef = ICONS[name]
if (!iconDef) return ""
const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
}

View file

@ -1,41 +0,0 @@
import { Spinner } from "@inkjs/ui"
import { memo, useMemo } from "react"
const THINKING_PHRASES = [
"Thinking",
"Pondering",
"Contemplating",
"Reticulating",
"Marinating",
"Actualizing",
"Crunching",
"Untangling",
"Summoning",
"Conjuring",
"Materializing",
"Synthesizing",
"Assembling",
"Percolating",
"Brewing",
"Manifesting",
"Cogitating",
]
interface LoadingTextProps {
children?: React.ReactNode
}
function LoadingText({ children }: LoadingTextProps) {
const randomPhrase = useMemo(() => {
const randomIndex = Math.floor(Math.random() * THINKING_PHRASES.length)
return THINKING_PHRASES[randomIndex]
}, [])
const childrenStr = children ? String(children) : ""
const useRandomPhrase = !children || childrenStr === "Thinking"
const label = useRandomPhrase ? `${randomPhrase}...` : `${childrenStr}...`
return <Spinner label={label} />
}
export default memo(LoadingText)

View file

@ -1,68 +0,0 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import * as theme from "../theme.js"
import ProgressBar from "./ProgressBar.js"
interface MetricsDisplayProps {
tokenUsage: TokenUsage
contextWindow: number
}
/**
* Formats a large number with K (thousands) or M (millions) suffix.
*
* Examples:
* - 1234 -> "1.2K"
* - 1234567 -> "1.2M"
* - 500 -> "500"
*/
function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`
}
if (num >= 1_000) {
return `${(num / 1_000).toFixed(1)}K`
}
return num.toString()
}
/**
* Formats cost as currency with $ prefix.
*
* Examples:
* - 0.12345 -> "$0.12"
* - 1.5 -> "$1.50"
*/
function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`
}
/**
* Displays task metrics in a compact format:
* $0.12 45.2K 8.7K [] 62%
*/
function MetricsDisplay({ tokenUsage, contextWindow }: MetricsDisplayProps) {
const { totalCost, totalTokensIn, totalTokensOut, contextTokens } = tokenUsage
return (
<Box>
<Text color={theme.text}>{formatCost(totalCost)}</Text>
<Text color={theme.dimText}> </Text>
<Text color={theme.dimText}>
<Text color={theme.text}>{formatNumber(totalTokensIn)}</Text>
</Text>
<Text color={theme.dimText}> </Text>
<Text color={theme.dimText}>
<Text color={theme.text}>{formatNumber(totalTokensOut)}</Text>
</Text>
<Text color={theme.dimText}> </Text>
<ProgressBar value={contextTokens} max={contextWindow} width={12} />
</Box>
)
}
export default memo(MetricsDisplay)
export { formatNumber, formatCost }

View file

@ -1,493 +0,0 @@
/**
* MultilineTextInput Component
*
* A multi-line text input for Ink CLI applications.
* Based on ink-multiline-input but simplified for our needs.
*
* Key behaviors:
* - Option+Enter (macOS) / Alt+Enter: Add new line (works reliably)
* - Shift+Enter: Add new line (requires terminal support for kitty keyboard protocol)
* - Enter: Submit
* - Backspace at start of line: Merge with previous line
* - Escape: Clear all lines
* - Arrow keys: Navigate within and between lines
*/
import { useState, useEffect, useMemo, useCallback, useRef } from "react"
import { Box, Text, useInput, type Key } from "ink"
import { isGlobalInputSequence } from "@/lib/utils/input.js"
export interface MultilineTextInputProps {
/**
* Current value (can contain newlines)
*/
value: string
/**
* Called when the value changes
*/
onChange: (value: string) => void
/**
* Called when user submits (Enter)
*/
onSubmit?: (value: string) => void
/**
* Called when user presses Escape
*/
onEscape?: () => void
/**
* Called when up arrow is pressed while cursor is on the first line
* Use this to trigger history navigation
*/
onUpAtFirstLine?: () => void
/**
* Called when down arrow is pressed while cursor is on the last line
* Use this to trigger history navigation
*/
onDownAtLastLine?: () => void
/**
* Placeholder text when empty
*/
placeholder?: string
/**
* Whether the input is active/focused
*/
isActive?: boolean
/**
* Whether to show the cursor
*/
showCursor?: boolean
/**
* Prompt character for the first line
*/
prompt?: string
/**
* Terminal width in columns - used for proper line wrapping
* If not provided, lines won't be wrapped
*/
columns?: number
}
/**
* Normalize line endings to LF (\n)
*/
function normalizeLineEndings(text: string): string {
if (text == null) return ""
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
/**
* Calculate line and column position from cursor index
*/
function getCursorPosition(value: string, cursorIndex: number): { line: number; col: number } {
const lines = value.split("\n")
let pos = 0
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!
const lineEnd = pos + line.length
if (cursorIndex <= lineEnd) {
return { line: i, col: cursorIndex - pos }
}
pos = lineEnd + 1 // +1 for newline
}
// Cursor at very end
return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
}
/**
* Calculate cursor index from line and column position
*/
function getIndexFromPosition(value: string, line: number, col: number): number {
const lines = value.split("\n")
let index = 0
for (let i = 0; i < line && i < lines.length; i++) {
index += lines[i]!.length + 1 // +1 for newline
}
const targetLine = lines[line] || ""
index += Math.min(col, targetLine.length)
return index
}
/**
* Represents a visual row after wrapping a logical line
*/
interface VisualRow {
text: string
logicalLineIndex: number
isFirstRowOfLine: boolean
startCol: number // column offset in the logical line
}
/**
* Wrap a logical line into visual rows based on available width.
* Uses word-boundary wrapping: prefers to break at spaces rather than
* in the middle of words.
*/
function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
if (availableWidth <= 0 || lineText.length < availableWidth) {
return [
{
text: lineText,
logicalLineIndex,
isFirstRowOfLine: true,
startCol: 0,
},
]
}
const rows: VisualRow[] = []
let remaining = lineText
let startCol = 0
let isFirst = true
while (remaining.length > 0) {
if (remaining.length < availableWidth) {
// Remaining text fits in one row
rows.push({
text: remaining,
logicalLineIndex,
isFirstRowOfLine: isFirst,
startCol,
})
break
}
// Find a good break point - prefer breaking at a space
let breakPoint = availableWidth
// Look backwards from availableWidth for a space
const searchStart = Math.min(availableWidth, remaining.length)
let spaceIndex = -1
for (let i = searchStart - 1; i >= 0; i--) {
if (remaining[i] === " ") {
spaceIndex = i
break
}
}
if (spaceIndex > 0) {
// Found a space - break after it (include the space in this row)
breakPoint = spaceIndex + 1
}
// else: no space found, break at availableWidth (mid-word break as fallback)
const chunk = remaining.slice(0, breakPoint)
rows.push({
text: chunk,
logicalLineIndex,
isFirstRowOfLine: isFirst,
startCol,
})
remaining = remaining.slice(breakPoint)
startCol += breakPoint
isFirst = false
}
return rows
}
export function MultilineTextInput({
value,
onChange,
onSubmit,
onEscape,
onUpAtFirstLine,
onDownAtLastLine,
placeholder = "",
isActive = true,
showCursor = true,
prompt = "> ",
columns,
}: MultilineTextInputProps) {
const [cursorIndex, setCursorIndex] = useState(value.length)
// Use refs to track the latest values for use in the useInput callback.
// This prevents stale closure issues when multiple keystrokes arrive
// faster than React can re-render.
const valueRef = useRef(value)
const cursorIndexRef = useRef(cursorIndex)
// Track the previous value prop to detect actual changes from the parent
const prevValuePropRef = useRef(value)
// Only sync valueRef when the value prop actually changes from the parent.
// This prevents overwriting our optimistic updates during re-renders
// triggered by internal state changes (like setCursorIndex) before the
// parent has processed our onChange call.
if (value !== prevValuePropRef.current) {
valueRef.current = value
prevValuePropRef.current = value
}
// cursorIndex is internal state, safe to sync on every render
cursorIndexRef.current = cursorIndex
// Clamp cursor if value changes externally
useEffect(() => {
if (cursorIndex > value.length) {
setCursorIndex(value.length)
}
}, [value, cursorIndex])
// Handle keyboard input
useInput(
(input: string, key: Key) => {
// Read from refs to get the latest values, not stale closure captures
const currentValue = valueRef.current
const currentCursorIndex = cursorIndexRef.current
// Escape: clear all
if (key.escape) {
onEscape?.()
return
}
// Ignore inputs that are handled at the App level (global shortcuts)
// This includes Ctrl+C (exit), Ctrl+M (mode toggle), etc.
if (isGlobalInputSequence(input, key)) {
return
}
// Option+Enter (macOS) / Alt+Enter / Shift+Enter: add new line
// When Option/Alt is held, the terminal sends \r but key.return is false.
// This allows us to distinguish it from a regular Enter.
// Also support various terminal encodings for Shift+Enter.
const isModifiedEnter =
(input === "\r" && !key.return) || // Option+Enter on macOS sends \r but key.return=false
(key.return && key.shift) || // Shift+Enter if terminal reports modifiers
input === "\x1b[13;2u" || // CSI u encoding for Shift+Enter
input === "\x1b[27;2;13~" || // xterm modifyOtherKeys encoding for Shift+Enter
input === "\x1b\r" || // Some terminals send ESC+CR for Shift+Enter
input === "\x1bOM" || // Some terminals
(input.startsWith("\x1b[") && input.includes(";2") && input.endsWith("u")) // General CSI u with shift modifier
if (isModifiedEnter) {
const newValue =
currentValue.slice(0, currentCursorIndex) + "\n" + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
return
}
// Enter: submit
if (key.return) {
onSubmit?.(currentValue)
return
}
// Tab: ignore for now
if (key.tab) {
return
}
// Arrow up: move cursor up one line, or trigger history if on first line
if (key.upArrow) {
if (!showCursor) return
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
if (line > 0) {
// Move to previous line
const targetLine = lines[line - 1]!
const newCol = Math.min(col, targetLine.length)
const newCursorIndex = getIndexFromPosition(currentValue, line - 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On first line - trigger history navigation callback
onUpAtFirstLine?.()
}
return
}
// Arrow down: move cursor down one line, or trigger history if on last line
if (key.downArrow) {
if (!showCursor) return
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
if (line < lines.length - 1) {
// Move to next line
const targetLine = lines[line + 1]!
const newCol = Math.min(col, targetLine.length)
const newCursorIndex = getIndexFromPosition(currentValue, line + 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On last line - trigger history navigation callback
onDownAtLastLine?.()
}
return
}
// Arrow left: move cursor left
if (key.leftArrow) {
if (!showCursor) return
const newCursorIndex = Math.max(0, currentCursorIndex - 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Arrow right: move cursor right
if (key.rightArrow) {
if (!showCursor) return
const newCursorIndex = Math.min(currentValue.length, currentCursorIndex + 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Backspace/Delete
if (key.backspace || key.delete) {
if (currentCursorIndex > 0) {
const newValue =
currentValue.slice(0, currentCursorIndex - 1) + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex - 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
}
return
}
// Normal character input
if (input) {
const normalized = normalizeLineEndings(input)
const newValue =
currentValue.slice(0, currentCursorIndex) + normalized + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + normalized.length
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
}
},
{ isActive },
)
// Split value into lines for rendering
const lines = useMemo(() => {
if (!value && !isActive) {
return [placeholder]
}
if (!value) {
return [""]
}
return value.split("\n")
}, [value, placeholder, isActive])
// Determine which line and column the cursor is on
const cursorPosition = useMemo(() => {
if (!showCursor || !isActive) return null
return getCursorPosition(value, cursorIndex)
}, [value, cursorIndex, showCursor, isActive])
// Calculate visual rows with wrapping
const visualRows = useMemo(() => {
const rows: VisualRow[] = []
const promptLen = prompt.length
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]!
// All rows use the same prefix width (prompt length) for consistent alignment
const prefixLen = promptLen
// Calculate available width for text (terminal width minus prefix)
// Use a large number if columns is not provided
const availableWidth = columns ? Math.max(1, columns - prefixLen) : 10000
const lineRows = wrapLine(lineText, i, availableWidth)
rows.push(...lineRows)
}
return rows
}, [lines, columns, prompt.length])
// Render a visual row with optional cursor
// Uses a two-column flex layout to ensure all text is vertically aligned:
// - Column 1: Fixed width for the prompt (only shown on first row)
// - Column 2: Text content
const renderVisualRow = useCallback(
(row: VisualRow, rowIndex: number) => {
const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0
const promptWidth = prompt.length
// Only show the prompt on the very first visual row (first row of first line)
const showPrompt = row.logicalLineIndex === 0 && row.isFirstRowOfLine
// Check if cursor is on this visual row
let hasCursor = false
let cursorColInRow = -1
if (cursorPosition && cursorPosition.line === row.logicalLineIndex && isActive) {
const cursorCol = cursorPosition.col
// Check if cursor falls within this visual row's range
if (cursorCol >= row.startCol && cursorCol < row.startCol + row.text.length) {
hasCursor = true
cursorColInRow = cursorCol - row.startCol
}
// Cursor at the end of this row (for the last row of a line)
else if (cursorCol === row.startCol + row.text.length) {
// Check if this is the last visual row for this logical line
const nextRow = visualRows[rowIndex + 1]
if (!nextRow || nextRow.logicalLineIndex !== row.logicalLineIndex) {
hasCursor = true
cursorColInRow = row.text.length
}
}
}
if (hasCursor) {
const beforeCursor = row.text.slice(0, cursorColInRow)
const cursorAtEnd = cursorColInRow >= row.text.length
const cursorChar = cursorAtEnd ? " " : row.text[cursorColInRow]!
const afterCursor = cursorAtEnd ? "" : row.text.slice(cursorColInRow + 1)
// Check if adding cursor space at end would overflow the line width.
// When cursor is at the end of a max-width row, rendering an extra space
// would push the content beyond the terminal width, causing visual shift.
const wouldOverflow =
columns !== undefined && cursorAtEnd && promptWidth + row.text.length + 1 > columns
if (wouldOverflow) {
// Don't add extra space - cursor will appear at start of next row when text wraps
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text>{row.text}</Text>
</Box>
)
}
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text>{beforeCursor}</Text>
<Text inverse>{cursorChar}</Text>
<Text>{afterCursor}</Text>
</Box>
)
}
// For rows without cursor, use a space for empty text to ensure the row has height
// This fixes the issue where empty newlines don't expand the component height
const displayText = row.text.length === 0 ? " " : row.text
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text dimColor={isPlaceholder}>{displayText}</Text>
</Box>
)
},
[prompt, cursorPosition, value, isActive, visualRows, columns],
)
return <Box flexDirection="column">{visualRows.map((row, index) => renderVisualRow(row, index))}</Box>
}

View file

@ -1,61 +0,0 @@
import { memo } from "react"
import { Text } from "ink"
import * as theme from "../theme.js"
interface ProgressBarProps {
/** Current value (e.g., contextTokens) */
value: number
/** Maximum value (e.g., contextWindow) */
max: number
/** Width of the bar in characters (default: 16) */
width?: number
}
/**
* A progress bar component with color gradient based on fill percentage.
*
* Colors:
* - 0-50%: Green (safe zone)
* - 50-75%: Yellow (warning zone)
* - 75-100%: Red (danger zone)
*
* Visual example: [] 50%
*/
function ProgressBar({ value, max, width = 16 }: ProgressBarProps) {
// Calculate percentage, clamped to 0-100
const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0
// Calculate how many blocks to fill
const filledBlocks = Math.round((percentage / 100) * width)
const emptyBlocks = width - filledBlocks
// Determine color based on percentage
let barColor: string
if (percentage <= 50) {
barColor = theme.successColor // Green
} else if (percentage <= 75) {
barColor = theme.warningColor // Yellow
} else {
barColor = theme.errorColor // Red
}
// Unicode block characters for smooth appearance
const filledChar = "█"
const emptyChar = "░"
const filledPart = filledChar.repeat(filledBlocks)
const emptyPart = emptyChar.repeat(emptyBlocks)
return (
<Text>
<Text color={theme.dimText}>[</Text>
<Text color={barColor}>{filledPart}</Text>
<Text color={theme.dimText}>
{emptyPart}] {Math.round(percentage)}%
</Text>
</Text>
)
}
export default memo(ProgressBar)

View file

@ -1,398 +0,0 @@
import { Box, DOMElement, measureElement, Text, useInput } from "ink"
import { useEffect, useReducer, useRef, useCallback, useMemo, useState } from "react"
import * as theme from "../theme.js"
interface ScrollAreaState {
innerHeight: number
height: number
scrollTop: number
autoScroll: boolean
}
function calculateScrollbar(
viewportHeight: number,
contentHeight: number,
scrollTop: number,
): { handleStart: number; handleHeight: number; maxScroll: number } {
const maxScroll = Math.max(0, contentHeight - viewportHeight)
if (contentHeight <= viewportHeight || maxScroll === 0) {
// No scrolling needed - handle fills entire track
return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 }
}
// Calculate handle height as ratio of viewport to content (minimum 1 line)
const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight))
// Calculate handle position
const trackSpace = viewportHeight - handleHeight
const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0
const handleStart = Math.round(scrollRatio * trackSpace)
return { handleStart, handleHeight, maxScroll }
}
type ScrollAreaAction =
| { type: "SET_INNER_HEIGHT"; innerHeight: number }
| { type: "SET_HEIGHT"; height: number }
| { type: "SCROLL_DOWN"; amount?: number }
| { type: "SCROLL_UP"; amount?: number }
| { type: "SCROLL_TO_BOTTOM" }
| { type: "SCROLL_TO_LINE"; line: number }
| { type: "SET_AUTO_SCROLL"; autoScroll: boolean }
function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState {
const maxScroll = Math.max(0, state.innerHeight - state.height)
switch (action.type) {
case "SET_INNER_HEIGHT": {
const newMaxScroll = Math.max(0, action.innerHeight - state.height)
// If auto-scroll is enabled and content grew, scroll to bottom
if (state.autoScroll && action.innerHeight > state.innerHeight) {
return {
...state,
innerHeight: action.innerHeight,
scrollTop: newMaxScroll,
}
}
// Clamp scrollTop to valid range
return {
...state,
innerHeight: action.innerHeight,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SET_HEIGHT": {
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
// If auto-scroll is enabled, stay at bottom
if (state.autoScroll) {
return {
...state,
height: action.height,
scrollTop: newMaxScroll,
}
}
// Clamp scrollTop to valid range
return {
...state,
height: action.height,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SCROLL_DOWN": {
const amount = action.amount || 1
const newScrollTop = Math.min(maxScroll, state.scrollTop + amount)
// If we scroll to the bottom, re-enable auto-scroll
const atBottom = newScrollTop >= maxScroll
return {
...state,
scrollTop: newScrollTop,
autoScroll: atBottom,
}
}
case "SCROLL_UP": {
const amount = action.amount || 1
const newScrollTop = Math.max(0, state.scrollTop - amount)
// Disable auto-scroll when user scrolls up
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
case "SCROLL_TO_BOTTOM":
return {
...state,
scrollTop: maxScroll,
autoScroll: true,
}
case "SCROLL_TO_LINE": {
// Scroll to make a specific line visible
// If line is above viewport, scroll up to show it at the top
// If line is below viewport, scroll down to show it at the bottom
const line = action.line
const viewportBottom = state.scrollTop + state.height - 1
if (line < state.scrollTop) {
// Line is above viewport - scroll up to show it at the top
return {
...state,
scrollTop: Math.max(0, line),
autoScroll: false,
}
} else if (line > viewportBottom) {
// Line is below viewport - scroll down to show it at the bottom
const newScrollTop = Math.min(maxScroll, line - state.height + 1)
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
// Line is already visible - no change needed
return state
}
case "SET_AUTO_SCROLL":
return {
...state,
autoScroll: action.autoScroll,
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
}
default:
return state
}
}
export interface ScrollAreaProps {
height?: number
children: React.ReactNode
isActive?: boolean
onScroll?: (scrollTop: number, maxScroll: number, isAtBottom: boolean) => void
showBorder?: boolean
scrollToBottomTrigger?: number
scrollToLine?: number
scrollToLineTrigger?: number
showScrollbar?: boolean
/** Whether to auto-scroll to bottom when content grows. Default: true */
autoScroll?: boolean
}
export function ScrollArea({
height: heightProp,
children,
isActive = true,
onScroll,
showBorder = false,
scrollToBottomTrigger,
scrollToLine,
scrollToLineTrigger,
showScrollbar = true,
autoScroll: autoScrollProp = true,
}: ScrollAreaProps) {
// Ref for measuring outer container height when not provided
const outerRef = useRef<DOMElement>(null)
const [measuredHeight, setMeasuredHeight] = useState(0)
// Use provided height or measured height
const height = heightProp ?? measuredHeight
const [state, dispatch] = useReducer(reducer, {
height: height,
scrollTop: 0,
innerHeight: 0,
autoScroll: autoScrollProp,
})
const innerRef = useRef<DOMElement>(null)
const lastMeasuredHeight = useRef<number>(0)
// Track previous scrollToLineTrigger to detect actual changes (allows scrolling to index 0)
const prevScrollToLineTriggerRef = useRef<number | undefined>(undefined)
// Update height when prop changes
useEffect(() => {
if (height > 0) {
dispatch({ type: "SET_HEIGHT", height })
}
}, [height])
// Measure outer container height when no height prop is provided
useEffect(() => {
if (heightProp !== undefined) return // Skip if height is provided
const measureOuter = () => {
if (!outerRef.current) return
const dimensions = measureElement(outerRef.current)
if (dimensions.height !== measuredHeight && dimensions.height > 0) {
setMeasuredHeight(dimensions.height)
}
}
// Initial measurement
measureOuter()
// Re-measure periodically to catch layout changes
const interval = setInterval(measureOuter, 100)
return () => {
clearInterval(interval)
}
}, [heightProp, measuredHeight])
// Scroll to bottom when trigger changes
useEffect(() => {
if (scrollToBottomTrigger !== undefined && scrollToBottomTrigger > 0) {
dispatch({ type: "SCROLL_TO_BOTTOM" })
}
}, [scrollToBottomTrigger])
// Scroll to specific line when trigger changes
// FIX: Use ref to detect actual changes instead of `> 0` check, which broke scrolling to index 0
useEffect(() => {
const prevTrigger = prevScrollToLineTriggerRef.current
const triggerChanged = scrollToLineTrigger !== prevTrigger
// Only dispatch if trigger actually changed and we have valid values
// This allows scrolling to index 0 (which was broken by the old `> 0` check)
if (triggerChanged && scrollToLineTrigger !== undefined && scrollToLine !== undefined) {
dispatch({ type: "SCROLL_TO_LINE", line: scrollToLine })
}
// Update the ref to track the current trigger value
prevScrollToLineTriggerRef.current = scrollToLineTrigger
}, [scrollToLineTrigger, scrollToLine])
// Measure inner content height - use MutationObserver pattern for dynamic content
useEffect(() => {
if (!innerRef.current) return
const measureAndUpdate = () => {
if (!innerRef.current) return
const dimensions = measureElement(innerRef.current)
if (dimensions.height !== lastMeasuredHeight.current) {
lastMeasuredHeight.current = dimensions.height
dispatch({ type: "SET_INNER_HEIGHT", innerHeight: dimensions.height })
}
}
// Initial measurement
measureAndUpdate()
// Re-measure periodically while component is mounted
// This handles streaming content that changes size
const interval = setInterval(measureAndUpdate, 100)
return () => {
clearInterval(interval)
}
}, [children])
// Notify parent of scroll changes
useEffect(() => {
if (onScroll) {
const maxScroll = Math.max(0, state.innerHeight - state.height)
const isAtBottom = state.scrollTop >= maxScroll || maxScroll === 0
onScroll(state.scrollTop, maxScroll, isAtBottom)
}
}, [state.scrollTop, state.innerHeight, state.height, onScroll])
// Handle keyboard input for scrolling
useInput(
(_input, key) => {
if (!isActive) return
if (key.downArrow) {
dispatch({ type: "SCROLL_DOWN" })
}
if (key.upArrow) {
dispatch({ type: "SCROLL_UP" })
}
if (key.pageDown) {
dispatch({ type: "SCROLL_DOWN", amount: Math.floor(state.height / 2) })
}
if (key.pageUp) {
dispatch({ type: "SCROLL_UP", amount: Math.floor(state.height / 2) })
}
// Home - scroll to top
if (key.ctrl && _input === "a") {
dispatch({ type: "SCROLL_UP", amount: state.scrollTop })
}
// End - scroll to bottom
if (key.ctrl && _input === "e") {
dispatch({ type: "SCROLL_TO_BOTTOM" })
}
},
{ isActive },
)
// Calculate scrollbar dimensions
const scrollbar = useMemo(() => {
return calculateScrollbar(state.height, state.innerHeight, state.scrollTop)
}, [state.height, state.innerHeight, state.scrollTop])
// Determine if scrollbar should be visible
// Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state)
// Hide scrollbar only when: not focused AND nothing to scroll
const showScrollbarVisible = showScrollbar && (scrollbar.maxScroll > 0 || isActive)
// Scrollbar colors based on focus state
// When active: handle is bright purple, track is muted
// When inactive: handle is dim gray, track is more muted
const handleColor = isActive ? theme.scrollActiveColor : theme.dimText
const trackColor = theme.scrollTrackColor
// When no height prop is provided, use flexGrow to fill available space
const useFlexGrow = heightProp === undefined
return (
<Box
ref={outerRef}
flexDirection="row"
height={useFlexGrow ? undefined : height}
flexGrow={useFlexGrow ? 1 : undefined}
flexShrink={useFlexGrow ? 1 : undefined}
overflow="hidden">
{/* Scroll content area */}
<Box
height={useFlexGrow ? undefined : height}
borderStyle={showBorder ? "single" : undefined}
flexDirection="column"
flexGrow={1}
flexShrink={1}
overflow="hidden">
<Box ref={innerRef} flexShrink={0} flexDirection="column" marginTop={-state.scrollTop}>
{children}
</Box>
</Box>
{/* Scrollbar - rendered with separate colors for handle and track */}
{showScrollbar && (
<Box flexDirection="column" width={1} flexShrink={0} overflow="hidden">
{showScrollbarVisible &&
height > 0 &&
Array(height)
.fill(null)
.map((_, i) => {
const isHandle =
i >= scrollbar.handleStart && i < scrollbar.handleStart + scrollbar.handleHeight
return (
<Text key={i} color={isHandle ? handleColor : trackColor}>
{isHandle ? "┃" : "│"}
</Text>
)
})}
</Box>
)}
</Box>
)
}
/**
* Hook to use with ScrollArea for external control
*/
export function useScrollToBottom() {
const triggerRef = useRef(0)
const [, forceUpdate] = useReducer((x) => x + 1, 0)
const scrollToBottom = useCallback(() => {
triggerRef.current += 1
forceUpdate()
}, [])
return {
scrollToBottomTrigger: triggerRef.current,
scrollToBottom,
}
}

View file

@ -1,26 +0,0 @@
import { Box, Text } from "ink"
import { memo } from "react"
import * as theme from "../theme.js"
interface ScrollIndicatorProps {
scrollTop: number
maxScroll: number
isScrollFocused?: boolean
}
function ScrollIndicator({ scrollTop, maxScroll, isScrollFocused = false }: ScrollIndicatorProps) {
// Calculate percentage - show 100% when at bottom or no scrolling needed.
const percentage = maxScroll > 0 ? Math.round((scrollTop / maxScroll) * 100) : 100
// Color changes based on focus state.
const color = isScrollFocused ? theme.scrollActiveColor : theme.dimText
return (
<Box>
<Text color={color}>{percentage}% scroll Ctrl+E end</Text>
</Box>
)
}
export default memo(ScrollIndicator)

View file

@ -1,56 +0,0 @@
import { memo } from "react"
import { Text, Box } from "ink"
import * as theme from "../theme.js"
import type { Toast, ToastType } from "../hooks/useToast.js"
interface ToastDisplayProps {
toast: Toast | null
}
function getToastColor(type: ToastType): string {
switch (type) {
case "success":
return theme.successColor
case "warning":
return theme.warningColor
case "error":
return theme.errorColor
case "info":
default:
return theme.focusColor // cyan for info
}
}
function getToastIcon(type: ToastType): string {
switch (type) {
case "success":
return "✓"
case "warning":
return "⚠"
case "error":
return "✗"
case "info":
default:
return ""
}
}
function ToastDisplay({ toast }: ToastDisplayProps) {
if (!toast) {
return null
}
const color = getToastColor(toast.type)
const icon = getToastIcon(toast.type)
return (
<Box>
<Text color={color}>
{icon} {toast.message}
</Text>
</Box>
)
}
export default memo(ToastDisplay)

View file

@ -1,142 +0,0 @@
import { memo } from "react"
import { Box, Text } from "ink"
import type { TodoItem } from "@roo-code/types"
import * as theme from "../theme.js"
/**
* Status icons for TODO items using Unicode characters
*/
const STATUS_ICONS = {
completed: "✓",
in_progress: "→",
pending: "○",
} as const
/**
* Get the color for a TODO status
*/
function getStatusColor(status: TodoItem["status"]): string {
switch (status) {
case "completed":
return theme.successColor
case "in_progress":
return theme.warningColor
case "pending":
default:
return theme.dimText
}
}
interface TodoChangeDisplayProps {
/** Previous TODO list for comparison */
previousTodos: TodoItem[]
/** New TODO list */
newTodos: TodoItem[]
}
/**
* TodoChangeDisplay component for CLI
*
* Shows only the items that changed between two TODO lists.
* Used for compact inline display in the chat history.
*
* Visual example:
* ```
* TODO Updated
* Design architecture [completed]
* Implement core logic [started]
* ```
*/
function TodoChangeDisplay({ previousTodos, newTodos }: TodoChangeDisplayProps) {
if (!newTodos || newTodos.length === 0) {
return null
}
const isInitialState = previousTodos.length === 0
// Determine which todos to display
let todosToDisplay: TodoItem[]
if (isInitialState) {
// For initial state, show all todos
todosToDisplay = newTodos
} else {
// For updates, only show changes (completed or started items)
todosToDisplay = newTodos.filter((newTodo) => {
if (newTodo.status === "completed") {
const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content)
return !previousTodo || previousTodo.status !== "completed"
}
if (newTodo.status === "in_progress") {
const previousTodo = previousTodos.find((p) => p.id === newTodo.id || p.content === newTodo.content)
return !previousTodo || previousTodo.status !== "in_progress"
}
return false
})
}
// If no changes to display, show nothing
if (todosToDisplay.length === 0) {
return null
}
// Calculate progress for summary
const totalCount = newTodos.length
const completedCount = newTodos.filter((t) => t.status === "completed").length
return (
<Box flexDirection="column" paddingX={1}>
{/* Header with progress summary */}
<Box>
<Text color={theme.toolHeader} bold>
TODO {isInitialState ? "List" : "Updated"}
</Text>
<Text color={theme.dimText}>
{" "}
({completedCount}/{totalCount})
</Text>
</Box>
{/* Changed items */}
<Box flexDirection="column" paddingLeft={2}>
{todosToDisplay.map((todo, index) => {
const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.pending
const color = getStatusColor(todo.status)
// Determine what changed
const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content)
let changeLabel: string | null = null
if (isInitialState) {
// Don't show labels for initial state
changeLabel = null
} else if (!previousTodo) {
changeLabel = "new"
} else if (todo.status === "completed" && previousTodo.status !== "completed") {
changeLabel = "done"
} else if (todo.status === "in_progress" && previousTodo.status !== "in_progress") {
changeLabel = "started"
}
return (
<Box key={todo.id || `todo-${index}`}>
<Text color={color}>
{icon} {todo.content}
</Text>
{changeLabel && (
<Text color={theme.dimText} dimColor>
{" "}
[{changeLabel}]
</Text>
)}
</Box>
)
})}
</Box>
</Box>
)
}
export default memo(TodoChangeDisplay)

View file

@ -1,163 +0,0 @@
import { memo } from "react"
import { Box, Text } from "ink"
import type { TodoItem } from "@roo-code/types"
import * as theme from "../theme.js"
import ProgressBar from "./ProgressBar.js"
import { Icon, type IconName } from "./Icon.js"
/**
* Map TODO status to Icon names
*/
const STATUS_ICON_NAMES: Record<TodoItem["status"], IconName> = {
completed: "checkbox-checked",
in_progress: "checkbox-progress",
pending: "checkbox",
}
/**
* Get the color for a TODO status
*/
function getStatusColor(status: TodoItem["status"]): string {
switch (status) {
case "completed":
return theme.successColor
case "in_progress":
return theme.warningColor
case "pending":
default:
return theme.dimText
}
}
interface TodoDisplayProps {
/** List of TODO items to display */
todos: TodoItem[]
/** Previous TODO list for diff comparison (optional) */
previousTodos?: TodoItem[]
/** Whether to show the progress bar (default: true) */
showProgress?: boolean
/** Whether to show only changed items (default: false) */
showChangesOnly?: boolean
/** Title to display in the header (default: "Progress") */
title?: string
}
/**
* TodoDisplay component for CLI
*
* Renders a beautiful TODO list visualization with:
* - Nerd Font icons (or ASCII fallbacks) for status
* - Color-coded items based on status (green/yellow/gray)
* - Progress bar showing completion percentage
* - Optional diff mode showing only changed items
* - Change indicators ([done], [started], [new])
*
* Visual example (with fallback icons):
* ```
* Progress [] 2/5
* Analyze requirements [done]
* Design architecture [done]
* Implement core logic
* Write tests
* Update documentation [new]
* ```
*/
function TodoDisplay({
todos,
previousTodos = [],
showProgress = true,
showChangesOnly = false,
title = "Progress",
}: TodoDisplayProps) {
if (!todos || todos.length === 0) {
return null
}
// Determine which todos to display
let displayTodos: TodoItem[]
if (showChangesOnly && previousTodos.length > 0) {
// Filter to only show items that changed status
displayTodos = todos.filter((todo) => {
const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content)
if (!previousTodo) {
// New item
return true
}
// Status changed
return previousTodo.status !== todo.status
})
} else {
displayTodos = todos
}
// If filtering and nothing changed, don't render
if (showChangesOnly && displayTodos.length === 0) {
return null
}
// Calculate progress statistics
const totalCount = todos.length
const completedCount = todos.filter((t) => t.status === "completed").length
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header with progress bar on same line */}
<Box>
<Icon name="todo-list" color={theme.toolHeader} />
<Text color={theme.toolHeader} bold>
{" "}
{title}
</Text>
{showProgress && (
<>
<Text> </Text>
<ProgressBar value={completedCount} max={totalCount} width={16} />
</>
)}
</Box>
{/* TODO items */}
<Box flexDirection="column" paddingLeft={1} marginTop={1}>
{displayTodos.map((todo, index) => {
const iconName = STATUS_ICON_NAMES[todo.status] || STATUS_ICON_NAMES.pending
const color = getStatusColor(todo.status)
// Check if this item changed status
const previousTodo = previousTodos.find((p) => p.id === todo.id || p.content === todo.content)
const statusChanged = previousTodo && previousTodo.status !== todo.status
const isNew = previousTodos.length > 0 && !previousTodo
return (
<Box key={todo.id || `todo-${index}`}>
<Icon name={iconName} color={color} />
<Text color={color}> {todo.content}</Text>
{statusChanged && (
<Text color={theme.dimText} dimColor>
{" "}
[
{todo.status === "completed"
? "done"
: todo.status === "in_progress"
? "started"
: "reset"}
]
</Text>
)}
{isNew && (
<Text color={theme.dimText} dimColor>
{" "}
[new]
</Text>
)}
</Box>
)
})}
</Box>
</Box>
)
}
export default memo(TodoDisplay)

View file

@ -1,385 +0,0 @@
import { render } from "ink-testing-library"
import type { TUIMessage } from "../../types.js"
import ChatHistoryItem from "../ChatHistoryItem.js"
import { resetNerdFontCache } from "../Icon.js"
describe("ChatHistoryItem", () => {
beforeEach(() => {
// Use fallback icons in tests so they render as visible characters
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
})
afterEach(() => {
delete process.env.ROOCODE_NERD_FONT
resetNerdFontCache()
})
describe("content sanitization", () => {
it("sanitizes tabs in user messages", () => {
const message: TUIMessage = {
id: "1",
role: "user",
content: "function test() {\n\treturn true;\n}",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// Tabs should be replaced with 4 spaces
expect(output).toContain("function test() {")
expect(output).toContain(" return true;") // Tab replaced with 4 spaces
expect(output).not.toContain("\t")
})
it("sanitizes tabs in assistant messages", () => {
const message: TUIMessage = {
id: "2",
role: "assistant",
content: "Here's the code:\n\tconst x = 1;",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain(" const x = 1;")
expect(output).not.toContain("\t")
})
it("sanitizes tabs in thinking messages", () => {
const message: TUIMessage = {
id: "3",
role: "thinking",
content: "Looking at:\n\tMarkdown example:\n\t```ts\n\t\tfunction foo() {}\n\t```",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// All tabs should be converted to spaces
expect(output).not.toContain("\t")
expect(output).toContain(" Markdown example:")
expect(output).toContain(" function foo() {}") // Double-indented
})
it("sanitizes tabs in tool messages with parsed content", () => {
// Tool messages parse JSON content to extract fields like 'content'
const message: TUIMessage = {
id: "4",
role: "tool",
content: JSON.stringify({
tool: "read_file",
path: "test.js",
content: "function() {\n\treturn true;\n}",
}),
toolName: "read_file",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// The content inside the JSON should be sanitized
expect(output).toContain(" return true;")
expect(output).not.toContain("\t")
})
it("sanitizes tabs in tool messages with toolDisplayOutput", () => {
const message: TUIMessage = {
id: "5",
role: "tool",
content: "raw content",
toolDisplayOutput: "function() {\n\treturn;\n}",
toolName: "execute_command",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// toolDisplayOutput should be used and sanitized
expect(output).toContain(" return;")
expect(output).not.toContain("\t")
})
it("sanitizes tabs in system messages", () => {
const message: TUIMessage = {
id: "6",
role: "system",
content: "System info:\n\tCPU: high",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain(" CPU: high")
expect(output).not.toContain("\t")
})
it("strips carriage returns from content", () => {
const message: TUIMessage = {
id: "7",
role: "thinking",
content: "Line 1\r\nLine 2\rLine 3",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// Carriage returns should be stripped
expect(output).not.toContain("\r")
expect(output).toContain("Line 1")
expect(output).toContain("Line 2")
expect(output).toContain("Line 3")
})
it("strips carriage returns from toolDisplayOutput", () => {
const message: TUIMessage = {
id: "8",
role: "tool",
content: "raw",
toolDisplayOutput: "Output\r\nwith\rCR",
toolName: "test_tool",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).not.toContain("\r")
})
it("handles content with both tabs and carriage returns", () => {
const message: TUIMessage = {
id: "9",
role: "thinking",
content: "Code:\r\n\tfunction() {\r\n\t\treturn;\r\n\t}",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// Both should be sanitized
expect(output).not.toContain("\t")
expect(output).not.toContain("\r")
expect(output).toContain(" function()")
expect(output).toContain(" return;") // Double-indented
})
})
describe("message rendering", () => {
it("renders user messages with correct header", () => {
const message: TUIMessage = {
id: "1",
role: "user",
content: "Hello",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("You said:")
expect(output).toContain("Hello")
})
it("renders assistant messages with correct header", () => {
const message: TUIMessage = {
id: "2",
role: "assistant",
content: "Hi there",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("Roo said:")
expect(output).toContain("Hi there")
})
it("renders thinking messages with correct header", () => {
const message: TUIMessage = {
id: "3",
role: "thinking",
content: "Let me think...",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("Roo is thinking:")
expect(output).toContain("Let me think...")
})
it("renders tool messages with icon and tool display name", () => {
const message: TUIMessage = {
id: "4",
role: "tool",
content: JSON.stringify({ tool: "read_file", path: "test.txt", content: "Output text" }),
toolName: "read_file",
toolDisplayName: "Read File",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// ToolDisplay (fallback without toolData) shows display name without icon
expect(output).toContain("Read File")
expect(output).toContain("Output text")
})
it("renders tool messages with path indicator for file tools", () => {
const message: TUIMessage = {
id: "5",
role: "tool",
content: JSON.stringify({ tool: "read_file", path: "src/test.ts", content: "file content" }),
toolName: "read_file",
toolDisplayName: "Read File",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("file:")
expect(output).toContain("src/test.ts")
})
it("renders tool messages with directory path indicator for list tools", () => {
const message: TUIMessage = {
id: "6",
role: "tool",
content: JSON.stringify({ tool: "listFilesRecursive", path: "src/", content: "file1\nfile2" }),
toolName: "listFilesRecursive",
toolDisplayName: "List Files",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("dir:")
expect(output).toContain("src/")
})
it("shows outside workspace warning when applicable", () => {
const message: TUIMessage = {
id: "7",
role: "tool",
content: JSON.stringify({
tool: "read_file",
path: "/etc/hosts",
isOutsideWorkspace: true,
content: "hosts file",
}),
toolName: "read_file",
toolDisplayName: "Read File",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("outside workspace")
})
it("uses fallback content when message.content is empty", () => {
const message: TUIMessage = {
id: "8",
role: "assistant",
content: "",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("...")
})
it("returns null for unknown role", () => {
const message = {
id: "9",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: "unknown" as any,
content: "Test",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
expect(lastFrame()).toBe("")
})
it("renders command tools with command icon", () => {
const message: TUIMessage = {
id: "10",
role: "tool",
content: JSON.stringify({ tool: "execute_command" }),
toolName: "execute_command",
toolDisplayName: "Execute Command",
toolDisplayOutput: "command output",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// ToolDisplay (fallback without toolData) shows display name without icon
expect(output).toContain("Execute Command")
expect(output).toContain("command output")
})
it("renders search tools with search icon", () => {
const message: TUIMessage = {
id: "11",
role: "tool",
content: JSON.stringify({ tool: "search_files" }),
toolName: "search_files",
toolDisplayName: "Search Files",
toolDisplayOutput: "search results",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// ToolDisplay (fallback without toolData) shows display name without icon
expect(output).toContain("Search Files")
})
it("renders attempt_completion tool with CompletionTool renderer", () => {
const message: TUIMessage = {
id: "12",
role: "tool",
content: JSON.stringify({
tool: "attempt_completion",
result: "I've completed the task successfully.",
}),
toolName: "attempt_completion",
toolDisplayName: "Task Complete",
toolDisplayOutput: "✅ I've completed the task successfully.",
toolData: {
tool: "attempt_completion",
result: "I've completed the task successfully.",
},
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// CompletionTool renders the result content directly without icon or header
expect(output).toContain("I've completed the task successfully.")
})
it("renders ask_followup_question tool with CompletionTool renderer", () => {
const message: TUIMessage = {
id: "13",
role: "tool",
content: JSON.stringify({ tool: "ask_followup_question", question: "What color would you like?" }),
toolName: "ask_followup_question",
toolDisplayName: "Question",
toolDisplayOutput: "❓ What color would you like?",
toolData: {
tool: "ask_followup_question",
question: "What color would you like?",
},
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
// CompletionTool renders the question content directly without icon or header
expect(output).toContain("What color would you like?")
})
})
})

View file

@ -1,162 +0,0 @@
import { render } from "ink-testing-library"
import { Icon, isNerdFontSupported, resetNerdFontCache, getIconChar } from "../Icon.js"
describe("Icon", () => {
beforeEach(() => {
// Reset cache before each test
resetNerdFontCache()
// Clear environment variables
delete process.env.ROOCODE_NERD_FONT
})
afterEach(() => {
resetNerdFontCache()
delete process.env.ROOCODE_NERD_FONT
})
describe("rendering", () => {
it("should render folder icon", () => {
const { lastFrame } = render(<Icon name="folder" />)
// Should render something (either nerd font or fallback)
expect(lastFrame()).toBeDefined()
})
it("should render file icon", () => {
const { lastFrame } = render(<Icon name="file" />)
expect(lastFrame()).toBeDefined()
})
it("should render check icon", () => {
const { lastFrame } = render(<Icon name="check" />)
expect(lastFrame()).toBeDefined()
})
it("should render cross icon", () => {
const { lastFrame } = render(<Icon name="cross" />)
expect(lastFrame()).toBeDefined()
})
it("should apply color prop", () => {
const { lastFrame } = render(<Icon name="file" color="blue" />)
expect(lastFrame()).toBeDefined()
})
it("should return null for unknown icon name", () => {
// @ts-expect-error - testing invalid icon name
const { lastFrame } = render(<Icon name="unknown-icon" />)
expect(lastFrame()).toBe("")
})
})
describe("Nerd Font detection", () => {
it("should respect ROOCODE_NERD_FONT=1 environment variable", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(true)
})
it("should respect ROOCODE_NERD_FONT=true environment variable", () => {
process.env.ROOCODE_NERD_FONT = "true"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(true)
})
it("should respect ROOCODE_NERD_FONT=0 environment variable", () => {
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(false)
})
it("should respect ROOCODE_NERD_FONT=false environment variable", () => {
process.env.ROOCODE_NERD_FONT = "false"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(false)
})
it("should cache detection result", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
const first = isNerdFontSupported()
// Change env var - should still use cached value
process.env.ROOCODE_NERD_FONT = "0"
const second = isNerdFontSupported()
expect(first).toBe(true)
expect(second).toBe(true) // Still true because cached
})
it("should reset cache when resetNerdFontCache is called", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(true)
// Reset and change
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
expect(isNerdFontSupported()).toBe(false)
})
})
describe("useNerdFont prop override", () => {
it("should force Nerd Font when useNerdFont=true", () => {
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
const { lastFrame } = render(<Icon name="folder" useNerdFont={true} />)
// The nerd font icon is a surrogate pair
const frame = lastFrame() || ""
// Surrogate pair should be present (even if it renders oddly in tests)
expect(frame.length).toBeGreaterThan(0)
})
it("should force fallback when useNerdFont=false", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
const { lastFrame } = render(<Icon name="folder" useNerdFont={false} />)
const frame = lastFrame() || ""
// Fallback for folder is "▼" (single char)
expect(frame).toContain("▼")
})
})
describe("getIconChar", () => {
it("should return fallback character when Nerd Font disabled", () => {
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
expect(getIconChar("folder")).toBe("▼")
expect(getIconChar("file")).toBe("●")
expect(getIconChar("check")).toBe("✓")
expect(getIconChar("cross")).toBe("✗")
})
it("should return Nerd Font character when enabled", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
// Nerd Font icons are single characters (length 1)
expect(getIconChar("folder").length).toBe(1)
expect(getIconChar("file").length).toBe(1)
})
it("should respect useNerdFont override", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
// Force fallback
expect(getIconChar("folder", false)).toBe("▼")
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
// Force Nerd Font
expect(getIconChar("folder", true).length).toBe(1)
})
it("should return empty string for unknown icon", () => {
// @ts-expect-error - testing invalid icon name
expect(getIconChar("unknown")).toBe("")
})
})
})

View file

@ -1,86 +0,0 @@
import { render } from "ink-testing-library"
import type { Toast } from "../../hooks/useToast.js"
import ToastDisplay from "../ToastDisplay.js"
describe("ToastDisplay", () => {
it("should render nothing when toast is null", () => {
const { lastFrame } = render(<ToastDisplay toast={null} />)
expect(lastFrame()).toBe("")
})
it("should render info toast with cyan color and info icon", () => {
const toast: Toast = {
id: "test-1",
message: "Info message",
type: "info",
duration: 3000,
createdAt: Date.now(),
}
const { lastFrame } = render(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Info message")
expect(lastFrame()).toContain("")
})
it("should render success toast with success icon", () => {
const toast: Toast = {
id: "test-2",
message: "Success message",
type: "success",
duration: 3000,
createdAt: Date.now(),
}
const { lastFrame } = render(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Success message")
expect(lastFrame()).toContain("✓")
})
it("should render warning toast with warning icon", () => {
const toast: Toast = {
id: "test-3",
message: "Warning message",
type: "warning",
duration: 3000,
createdAt: Date.now(),
}
const { lastFrame } = render(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Warning message")
expect(lastFrame()).toContain("⚠")
})
it("should render error toast with error icon", () => {
const toast: Toast = {
id: "test-4",
message: "Error message",
type: "error",
duration: 3000,
createdAt: Date.now(),
}
const { lastFrame } = render(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Error message")
expect(lastFrame()).toContain("✗")
})
it("should display the full message", () => {
const toast: Toast = {
id: "test-5",
message: "Switched to Code mode",
type: "info",
duration: 2000,
createdAt: Date.now(),
}
const { lastFrame } = render(<ToastDisplay toast={toast} />)
expect(lastFrame()).toContain("Switched to Code mode")
})
})

View file

@ -1,149 +0,0 @@
import { render } from "ink-testing-library"
import type { TodoItem } from "@roo-code/types"
import TodoChangeDisplay from "../TodoChangeDisplay.js"
describe("TodoChangeDisplay", () => {
it("renders all todos for initial state (no previous todos)", () => {
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "in_progress" },
{ id: "3", content: "Task 3", status: "pending" },
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
const output = lastFrame()
// Check header shows "List" for initial state
expect(output).toContain("TODO List")
// All items should be shown
expect(output).toContain("Task 1")
expect(output).toContain("Task 2")
expect(output).toContain("Task 3")
// Progress should be shown
expect(output).toContain("(1/3)")
})
it("shows only changed items when previous todos exist", () => {
const previousTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "pending" },
{ id: "2", content: "Task 2", status: "pending" },
{ id: "3", content: "Task 3", status: "pending" },
]
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" }, // Changed to completed
{ id: "2", content: "Task 2", status: "in_progress" }, // Changed to in_progress
{ id: "3", content: "Task 3", status: "pending" }, // No change
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
const output = lastFrame()
// Header should say "Updated"
expect(output).toContain("TODO Updated")
// Only changed items should be shown
expect(output).toContain("Task 1")
expect(output).toContain("Task 2")
// Unchanged item should NOT be shown
// Note: We can check if "Task 3" appears but since rendering is compact,
// we'll check for change labels instead
expect(output).toContain("[done]")
expect(output).toContain("[started]")
})
it("returns null when no todos provided", () => {
const { lastFrame } = render(<TodoChangeDisplay previousTodos={[]} newTodos={[]} />)
expect(lastFrame()).toBe("")
})
it("returns null when no changes detected", () => {
const todos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "pending" },
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={todos} newTodos={todos} />)
// No changes means nothing to display
expect(lastFrame()).toBe("")
})
it("shows [new] label for newly added items", () => {
const previousTodos: TodoItem[] = [{ id: "1", content: "Task 1", status: "completed" }]
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "New Task", status: "in_progress" }, // New item
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
const output = lastFrame()
expect(output).toContain("New Task")
expect(output).toContain("[new]")
})
it("displays correct status icons", () => {
const newTodos: TodoItem[] = [
{ id: "1", content: "Completed task", status: "completed" },
{ id: "2", content: "In progress task", status: "in_progress" },
{ id: "3", content: "Pending task", status: "pending" },
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
const output = lastFrame()
// Check status icons
expect(output).toContain("✓") // completed
expect(output).toContain("→") // in_progress
expect(output).toContain("○") // pending
})
it("shows progress summary in header", () => {
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "completed" },
{ id: "3", content: "Task 3", status: "pending" },
{ id: "4", content: "Task 4", status: "pending" },
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
const output = lastFrame()
// 2 out of 4 completed
expect(output).toContain("(2/4)")
})
it("does not show labels for initial state items", () => {
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "in_progress" },
{ id: "2", content: "Task 2", status: "pending" },
]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={[]} newTodos={newTodos} />)
const output = lastFrame()
// Initial state should not have change labels like [done], [started], [new]
expect(output).not.toContain("[done]")
expect(output).not.toContain("[started]")
expect(output).not.toContain("[new]")
})
it("handles matching by content when ids differ", () => {
const previousTodos: TodoItem[] = [{ id: "old-1", content: "Same content task", status: "pending" }]
const newTodos: TodoItem[] = [{ id: "new-1", content: "Same content task", status: "completed" }]
const { lastFrame } = render(<TodoChangeDisplay previousTodos={previousTodos} newTodos={newTodos} />)
const output = lastFrame()
// Should recognize as the same task that changed status
expect(output).toContain("Same content task")
expect(output).toContain("[done]")
})
})

View file

@ -1,152 +0,0 @@
import { render } from "ink-testing-library"
import type { TodoItem } from "@roo-code/types"
import TodoDisplay from "../TodoDisplay.js"
import { resetNerdFontCache } from "../Icon.js"
describe("TodoDisplay", () => {
beforeEach(() => {
// Use fallback icons in tests so they render as visible characters
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
})
afterEach(() => {
delete process.env.ROOCODE_NERD_FONT
resetNerdFontCache()
})
const mockTodos: TodoItem[] = [
{ id: "1", content: "Analyze requirements", status: "completed" },
{ id: "2", content: "Design architecture", status: "completed" },
{ id: "3", content: "Implement core logic", status: "in_progress" },
{ id: "4", content: "Write tests", status: "pending" },
{ id: "5", content: "Update documentation", status: "pending" },
]
it("renders all todos with correct status icons", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} />)
const output = lastFrame()
// Check header (default title is "Progress")
expect(output).toContain("Progress")
// Check all items are rendered
expect(output).toContain("Analyze requirements")
expect(output).toContain("Design architecture")
expect(output).toContain("Implement core logic")
expect(output).toContain("Write tests")
expect(output).toContain("Update documentation")
// Check status icons are present (fallback icons)
expect(output).toContain("✓") // completed
expect(output).toContain("→") // in_progress
expect(output).toContain("○") // pending
})
it("renders progress bar when showProgress is true", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} showProgress={true} />)
const output = lastFrame()
// Check progress bar shows percentage (2/5 = 40%)
expect(output).toContain("40%")
})
it("hides progress bar when showProgress is false", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} showProgress={false} />)
const output = lastFrame()
// Should not show completion stats
expect(output).not.toContain("2/5 completed")
})
it("returns null for empty todos array", () => {
const { lastFrame } = render(<TodoDisplay todos={[]} />)
expect(lastFrame()).toBe("")
})
it("shows only changed items when showChangesOnly is true", () => {
const previousTodos: TodoItem[] = [
{ id: "1", content: "Analyze requirements", status: "completed" },
{ id: "2", content: "Design architecture", status: "in_progress" },
{ id: "3", content: "Implement core logic", status: "pending" },
]
const newTodos: TodoItem[] = [
{ id: "1", content: "Analyze requirements", status: "completed" },
{ id: "2", content: "Design architecture", status: "completed" }, // Changed
{ id: "3", content: "Implement core logic", status: "in_progress" }, // Changed
]
const { lastFrame } = render(
<TodoDisplay todos={newTodos} previousTodos={previousTodos} showChangesOnly={true} />,
)
const output = lastFrame()
// Should show changed items
expect(output).toContain("Design architecture")
expect(output).toContain("Implement core logic")
// Unchanged item should still be there since we're just filtering by change
// The filter only removes items that haven't changed status
})
it("shows change labels for items that changed status", () => {
const previousTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "pending" },
{ id: "2", content: "Task 2", status: "in_progress" },
]
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "in_progress" },
{ id: "2", content: "Task 2", status: "completed" },
]
const { lastFrame } = render(<TodoDisplay todos={newTodos} previousTodos={previousTodos} />)
const output = lastFrame()
// Check change indicators
expect(output).toContain("[started]")
expect(output).toContain("[done]")
})
it("shows [new] label for new items", () => {
const previousTodos: TodoItem[] = [{ id: "1", content: "Task 1", status: "completed" }]
const newTodos: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "New Task", status: "pending" },
]
const { lastFrame } = render(<TodoDisplay todos={newTodos} previousTodos={previousTodos} />)
const output = lastFrame()
expect(output).toContain("New Task")
expect(output).toContain("[new]")
})
it("uses custom title when provided", () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} title="My Custom Title" />)
const output = lastFrame()
expect(output).toContain("My Custom Title")
})
it("calculates in_progress count correctly", () => {
const todosWithMultipleInProgress: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "in_progress" },
{ id: "3", content: "Task 3", status: "in_progress" },
{ id: "4", content: "Task 4", status: "pending" },
]
const { lastFrame } = render(<TodoDisplay todos={todosWithMultipleInProgress} showProgress={true} />)
const output = lastFrame()
// Progress bar shows percentage (1/4 = 25%)
expect(output).toContain("25%")
// In_progress items render with the arrow icon
expect(output).toContain("→") // in_progress indicator
})
})

View file

@ -1,321 +0,0 @@
import { useInput } from "ink"
import { useState, useCallback, useEffect, useImperativeHandle, forwardRef, useRef, type Ref } from "react"
import { useInputHistory } from "../../hooks/useInputHistory.js"
import { useTerminalSize } from "../../hooks/TerminalSizeContext.js"
import { MultilineTextInput } from "../MultilineTextInput.js"
import type { AutocompleteItem, AutocompleteTrigger, AutocompletePickerState } from "./types.js"
import { useAutocompletePicker } from "./useAutocompletePicker.js"
export interface AutocompleteInputProps<T extends AutocompleteItem = AutocompleteItem> {
/** Placeholder text when input is empty */
placeholder?: string
/** Called when user submits text (Enter without picker open) */
onSubmit: (value: string) => void
/** Whether the input is active/focused */
isActive?: boolean
/** Array of autocomplete triggers to enable */
triggers: AutocompleteTrigger<T>[]
/** Called when an item is selected from the picker */
onSelect?: (item: T) => void
/** Called when picker state changes - use this to render PickerSelect externally */
onPickerStateChange?: (state: AutocompletePickerState<T>) => void
/** Prompt character for the first line (default: "> ") */
prompt?: string
}
/**
* Ref handle for AutocompleteInput - allows parent to access picker state and actions
*/
export interface AutocompleteInputHandle<T extends AutocompleteItem = AutocompleteItem> {
/** Current picker state */
pickerState: AutocompletePickerState<T>
/** Handle item selection from external picker */
handleItemSelect: (item: T) => void
/** Handle index change from external picker */
handleIndexChange: (index: number) => void
/** Close the picker */
closePicker: () => void
/** Force refresh search results (used when async data arrives after initial search) */
refreshSearch: () => void
}
/**
* Inner component implementation
*/
function AutocompleteInputInner<T extends AutocompleteItem>(
{
placeholder = "Type your message...",
onSubmit,
isActive = true,
triggers,
onSelect,
onPickerStateChange,
prompt = "> ",
}: AutocompleteInputProps<T>,
ref: Ref<AutocompleteInputHandle<T>>,
) {
const [inputValue, setInputValue] = useState("")
// Counter to force re-mount of MultilineTextInput to move cursor to end
const [inputKeyCounter, setInputKeyCounter] = useState(0)
// Get terminal size for proper line wrapping
const { columns } = useTerminalSize()
// Autocomplete picker state
const [pickerState, pickerActions] = useAutocompletePicker(triggers)
// Input history
const { addEntry, historyValue, isBrowsing, resetBrowsing, history, draft, setDraft, navigateUp, navigateDown } =
useInputHistory({
isActive: isActive && !pickerState.isOpen,
getCurrentInput: () => inputValue,
})
const [wasBrowsing, setWasBrowsing] = useState(false)
// Track previous picker state values to avoid unnecessary parent updates
const prevPickerStateRef = useRef({
isOpen: pickerState.isOpen,
resultsLength: pickerState.results.length,
selectedIndex: pickerState.selectedIndex,
isLoading: pickerState.isLoading,
})
// Notify parent of picker state changes only when relevant properties change
// This prevents double renders from cascading state updates
useEffect(() => {
const prev = prevPickerStateRef.current
const curr = {
isOpen: pickerState.isOpen,
resultsLength: pickerState.results.length,
selectedIndex: pickerState.selectedIndex,
isLoading: pickerState.isLoading,
}
// Only notify if something visually relevant changed
if (
prev.isOpen !== curr.isOpen ||
prev.resultsLength !== curr.resultsLength ||
prev.selectedIndex !== curr.selectedIndex ||
prev.isLoading !== curr.isLoading
) {
prevPickerStateRef.current = curr
onPickerStateChange?.(pickerState)
}
}, [pickerState, onPickerStateChange])
// Handle history navigation
useEffect(() => {
if (isBrowsing && !wasBrowsing) {
if (historyValue !== null) {
setInputValue(historyValue)
}
} else if (!isBrowsing && wasBrowsing) {
setInputValue(draft)
} else if (isBrowsing && historyValue !== null && historyValue !== inputValue) {
setInputValue(historyValue)
}
setWasBrowsing(isBrowsing)
}, [isBrowsing, wasBrowsing, historyValue, draft, inputValue])
/**
* Get the last line from input value
*/
const getLastLine = useCallback((value: string): string => {
const lines = value.split("\n")
return lines[lines.length - 1] || ""
}, [])
/**
* Handle input value changes
*/
const handleChange = useCallback(
(value: string) => {
// Check for trigger activation
const lastLine = getLastLine(value)
const result = pickerActions.handleInputChange(value, lastLine)
// If trigger consumes its character, use the consumed value instead
const effectiveValue = result.consumedValue ?? value
setInputValue(effectiveValue)
// If user types while browsing history, exit browsing mode
// This prevents the history effect from overwriting their edits
if (isBrowsing) {
resetBrowsing(effectiveValue)
} else {
setDraft(effectiveValue)
}
},
[pickerActions, isBrowsing, setDraft, getLastLine, resetBrowsing],
)
/**
* Handle item selection from picker
*/
const handleItemSelect = useCallback(
(item: T) => {
const lastLine = getLastLine(inputValue)
const newValue = pickerActions.handleSelect(item, inputValue, lastLine)
setInputValue(newValue)
setDraft(newValue)
// Increment counter to force re-mount and move cursor to end
setInputKeyCounter((c) => c + 1)
// Notify parent
onSelect?.(item)
},
[inputValue, pickerActions, setDraft, getLastLine, onSelect],
)
/**
* Handle form submission
*/
const handleSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim()
if (!trimmed) {
return
}
// Don't submit if picker is open
if (pickerState.isOpen) {
return
}
await addEntry(trimmed)
resetBrowsing("")
setInputValue("")
onSubmit(trimmed)
},
[pickerState.isOpen, addEntry, resetBrowsing, onSubmit],
)
/**
* Handle escape key
*/
const handleEscape = useCallback(() => {
// If picker is open, close it without clearing text
if (pickerState.isOpen) {
pickerActions.handleClose()
return
}
// Clear all input on Escape when picker is not open
setInputValue("")
setDraft("")
resetBrowsing("")
}, [pickerState.isOpen, pickerActions, setDraft, resetBrowsing])
// Handle picker selection with Enter or Tab
useInput(
(_input, key) => {
if (!isActive || !pickerState.isOpen) {
return
}
// Select current item on Enter or Tab
if (key.return || key.tab) {
const selected = pickerState.results[pickerState.selectedIndex]
if (selected) {
handleItemSelect(selected)
}
}
},
{ isActive: isActive && pickerState.isOpen },
)
// Expose handle to parent via ref
useImperativeHandle(
ref,
() => ({
pickerState,
handleItemSelect,
handleIndexChange: pickerActions.handleIndexChange,
closePicker: pickerActions.handleClose,
refreshSearch: pickerActions.forceRefresh,
}),
[
pickerState,
handleItemSelect,
pickerActions.handleIndexChange,
pickerActions.handleClose,
pickerActions.forceRefresh,
],
)
return (
<MultilineTextInput
key={`autocomplete-input-${history.length}-${inputKeyCounter}`}
value={inputValue}
onChange={handleChange}
onSubmit={handleSubmit}
onEscape={handleEscape}
onUpAtFirstLine={navigateUp}
onDownAtLastLine={navigateDown}
placeholder={placeholder}
isActive={isActive}
showCursor={true}
prompt={prompt}
columns={columns}
/>
)
}
/**
* A multiline text input with autocomplete support.
*
* Features:
* - Multiline text editing with history
* - Trigger-based autocomplete (e.g., @ for files, / for commands)
* - Keyboard navigation in picker
* - Exposes picker state via ref for external picker rendering
*
* @template T - The type of autocomplete items
*
* @example
* ```tsx
* const inputRef = useRef<AutocompleteInputHandle<MyItem>>(null)
*
* <AutocompleteInput
* ref={inputRef}
* triggers={myTriggers}
* onSubmit={handleSubmit}
* onPickerStateChange={(state) => setPickerState(state)}
* />
*
* {pickerState.isOpen && (
* <PickerSelect
* results={pickerState.results}
* selectedIndex={pickerState.selectedIndex}
* onSelect={inputRef.current?.handleItemSelect}
* // ...
* />
* )}
* ```
*/
export const AutocompleteInput = forwardRef(AutocompleteInputInner) as <T extends AutocompleteItem>(
props: AutocompleteInputProps<T> & { ref?: Ref<AutocompleteInputHandle<T>> },
) => ReturnType<typeof AutocompleteInputInner>
/**
* Re-export types and hook for convenience
*/
export { useAutocompletePicker } from "./useAutocompletePicker.js"
export type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"

View file

@ -1,189 +0,0 @@
import { useRef, useMemo, type ReactNode } from "react"
import { Box, Text, useInput } from "ink"
import type { AutocompleteItem } from "./types.js"
export interface PickerSelectProps<T extends AutocompleteItem> {
/** Results to display in the picker */
results: T[]
/** Currently selected index */
selectedIndex: number
/** Maximum number of visible items */
maxVisible?: number
/** Called when an item is selected */
onSelect: (item: T) => void
/** Called when escape is pressed */
onEscape: () => void
/** Called when selection index changes */
onIndexChange: (index: number) => void
/** Render function for each item */
renderItem: (item: T, isSelected: boolean) => ReactNode
/** Message shown when results are empty */
emptyMessage?: string
/** Whether the picker accepts keyboard input */
isActive?: boolean
/** Whether search is in progress */
isLoading?: boolean
}
/**
* Compute visible window based on selected index.
* The window "follows" the selection, keeping it visible.
* Uses a ref to track the previous window position for smooth scrolling.
*/
function computeVisibleWindow(
selectedIndex: number,
totalItems: number,
maxVisible: number,
prevWindow: { from: number; to: number },
): { from: number; to: number } {
if (totalItems === 0) {
return { from: 0, to: 0 }
}
const visibleCount = Math.min(maxVisible, totalItems)
// If previous window was empty (fresh results), compute initial window
// This handles the case when results first appear
if (prevWindow.to === 0 || prevWindow.to <= prevWindow.from) {
const newFrom = Math.max(0, selectedIndex)
const newTo = Math.min(totalItems, newFrom + visibleCount)
return { from: newFrom, to: newTo }
}
// If selected index is within current window, keep the window
if (selectedIndex >= prevWindow.from && selectedIndex < prevWindow.to) {
// But clamp the window to valid bounds (in case totalItems changed)
const clampedFrom = Math.max(0, Math.min(prevWindow.from, totalItems - visibleCount))
const clampedTo = Math.min(totalItems, clampedFrom + visibleCount)
return { from: clampedFrom, to: clampedTo }
}
// If selected is below window, scroll down to show it at bottom
if (selectedIndex >= prevWindow.to) {
const newTo = Math.min(totalItems, selectedIndex + 1)
const newFrom = Math.max(0, newTo - visibleCount)
return { from: newFrom, to: newTo }
}
// If selected is above window, scroll up to show it at top
if (selectedIndex < prevWindow.from) {
const newFrom = Math.max(0, selectedIndex)
const newTo = Math.min(totalItems, newFrom + visibleCount)
return { from: newFrom, to: newTo }
}
return prevWindow
}
/**
* Generic picker dropdown component for autocomplete.
* Uses windowing approach (like @inkjs/ui) - only renders visible items.
* This eliminates flickering caused by ScrollArea's margin-based scrolling.
*
* @template T - The type of items to display
*/
export function PickerSelect<T extends AutocompleteItem>({
results,
selectedIndex,
maxVisible = 10,
onSelect,
onEscape,
onIndexChange,
renderItem,
emptyMessage = "No results found",
isActive = true,
isLoading = false,
}: PickerSelectProps<T>) {
// Track previous window position for smooth scrolling
const prevWindowRef = useRef({ from: 0, to: Math.min(maxVisible, results.length) })
// Compute visible window SYNCHRONOUSLY during render (no state, no useEffect)
// This ensures the correct items are rendered in a single pass
const visibleWindow = useMemo(() => {
const window = computeVisibleWindow(selectedIndex, results.length, maxVisible, prevWindowRef.current)
// Update ref for next render
prevWindowRef.current = window
return window
}, [selectedIndex, results.length, maxVisible])
// Handle keyboard input
useInput(
(_input, key) => {
if (!isActive) {
return
}
if (key.escape) {
onEscape()
return
}
if (key.return) {
const selected = results[selectedIndex]
if (selected) {
onSelect(selected)
}
return
}
if (key.upArrow) {
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : results.length - 1
onIndexChange(newIndex)
return
}
if (key.downArrow) {
const newIndex = selectedIndex < results.length - 1 ? selectedIndex + 1 : 0
onIndexChange(newIndex)
return
}
},
{ isActive },
)
// Compute visible items (the key optimization - only render what's visible)
const visibleItems = useMemo(() => {
return results.slice(visibleWindow.from, visibleWindow.to)
}, [results, visibleWindow.from, visibleWindow.to])
// Empty state - maintain consistent height
if (results.length === 0) {
const message = isLoading ? "Searching..." : emptyMessage
return (
<Box paddingLeft={2} height={maxVisible}>
<Text dimColor>{message}</Text>
</Box>
)
}
// Calculate if we need scroll indicators
const hasMoreAbove = visibleWindow.from > 0
const hasMoreBelow = visibleWindow.to < results.length
// Render only visible items (windowing approach)
return (
<Box flexDirection="column" height={maxVisible}>
{/* Scroll indicator - more items above */}
{hasMoreAbove && (
<Box paddingLeft={2}>
<Text dimColor> {visibleWindow.from} more</Text>
</Box>
)}
{/* Visible items */}
{visibleItems.map((result, visibleIndex) => {
const actualIndex = visibleWindow.from + visibleIndex
const isSelected = actualIndex === selectedIndex
return <Box key={result.key}>{renderItem(result, isSelected)}</Box>
})}
{/* Scroll indicator - more items below */}
{hasMoreBelow && (
<Box paddingLeft={2}>
<Text dimColor> {results.length - visibleWindow.to} more</Text>
</Box>
)}
</Box>
)
}

View file

@ -1,41 +0,0 @@
/**
* Autocomplete system for CLI input.
*
* This module provides a generic, extensible autocomplete system that supports
* multiple trigger patterns (like @ for files, / for commands) through a
* plugin-like trigger architecture.
*
* @example
* ```tsx
* import {
* AutocompleteInput,
* PickerSelect,
* useAutocompletePicker,
* createFileTrigger,
* createSlashCommandTrigger,
* } from './autocomplete'
*
* const triggers = [
* createFileTrigger({ onSearch, getResults }),
* createSlashCommandTrigger({ getCommands }),
* ]
*
* <AutocompleteInput
* triggers={triggers}
* onSubmit={handleSubmit}
* />
* ```
*/
// Main components
export { type AutocompleteInputProps, type AutocompleteInputHandle, AutocompleteInput } from "./AutocompleteInput.js"
export { type PickerSelectProps, PickerSelect } from "./PickerSelect.js"
// Hook
export { useAutocompletePicker } from "./useAutocompletePicker.js"
// Types
export * from "./types.js"
// Triggers
export * from "./triggers/index.js"

View file

@ -1,140 +0,0 @@
import { Box, Text } from "ink"
import Fuzzysort from "fuzzysort"
import { Icon } from "../../Icon.js"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
export interface FileResult extends AutocompleteItem {
path: string
type: "file" | "folder"
label?: string
}
/**
* Props for creating a file trigger
*/
export interface FileTriggerConfig {
/**
* Called when a search should be performed.
* This typically triggers an API call to search files.
*/
onSearch: (query: string) => void
/**
* Current search results from the store/API.
* Results are provided externally because file search is async.
*/
getResults: () => FileResult[]
}
/**
* Create a file trigger for @ mentions.
*
* This trigger activates when the user types @ followed by text,
* and allows selecting files to insert as @/path references.
*
* The file trigger uses async data fetching:
* - search() triggers the API call and returns [] immediately
* - When API responds, App.tsx calls forceRefresh()
* - refreshResults() then returns the actual results from the store
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for file mentions
*/
export function createFileTrigger(config: FileTriggerConfig): AutocompleteTrigger<FileResult> {
const { onSearch, getResults } = config
// Helper function to get results and apply fuzzy sorting
function getResultsWithFuzzySort(query: string): FileResult[] {
const results = getResults()
// Sort results by fuzzy match score (best matches first)
if (!query || results.length === 0) {
return results
}
const fuzzyResults = Fuzzysort.go(query, results, {
key: "path",
threshold: -10000, // Include all results
})
return fuzzyResults.map((result) => result.obj)
}
return {
id: "file",
triggerChar: "@",
position: "anywhere",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Find the last @ in the line
const atIndex = lineText.lastIndexOf("@")
if (atIndex === -1) {
return null
}
// Extract query after @
const query = lineText.substring(atIndex + 1)
// Close picker if query contains space (user finished typing)
if (query.includes(" ")) {
return null
}
// Unlike other triggers that only work at line-start, @ can appear anywhere
// and should show results even with an empty query (just "@" typed)
return { query, triggerIndex: atIndex }
},
search: (query: string): FileResult[] => {
// Trigger the external async search
onSearch(query)
// Return empty immediately - don't bother calling getResults() since
// we know the async API hasn't responded yet.
// When results arrive, App.tsx will call forceRefresh() which uses
// refreshResults() to get the actual data from the store.
return []
},
// refreshResults: Get current results without triggering a new API call
// This is used by forceRefresh when async results arrive
refreshResults: (query: string): FileResult[] => {
return getResultsWithFuzzySort(query)
},
renderItem: (item: FileResult, isSelected: boolean) => {
const iconName = item.type === "folder" ? "folder" : "file"
const color = isSelected ? "cyan" : item.type === "folder" ? "blue" : undefined
return (
<Box paddingLeft={2}>
<Icon name={iconName} color={color} />
<Text> </Text>
<Text color={color}>{item.path}</Text>
</Box>
)
},
getReplacementText: (item: FileResult, lineText: string, triggerIndex: number): string => {
const beforeAt = lineText.substring(0, triggerIndex)
return `${beforeAt}@/${item.path} `
},
emptyMessage: "No matching files found",
debounceMs: 150,
}
}
/**
* Convert external FileSearchResult to FileResult.
* Use this to adapt results from the store to the trigger's expected type.
*/
export function toFileResult(result: { path: string; type: "file" | "folder"; label?: string }): FileResult {
return {
key: result.path,
path: result.path,
type: result.type,
label: result.label,
}
}

View file

@ -1,109 +0,0 @@
import { Box, Text } from "ink"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* Help shortcut result type.
* Represents a keyboard shortcut or trigger hint.
*/
export interface HelpShortcutResult extends AutocompleteItem {
/** The shortcut key or trigger character */
shortcut: string
/** Description of what the shortcut does */
description: string
}
/**
* Built-in shortcuts to display in the help menu.
*/
const HELP_SHORTCUTS: HelpShortcutResult[] = [
{ key: "slash", shortcut: "/", description: "for commands" },
{ key: "at", shortcut: "@", description: "for file paths" },
{ key: "bang", shortcut: "!", description: "for modes" },
{ key: "hash", shortcut: "#", description: "for task history" },
{ key: "newline", shortcut: "shift + ⏎", description: "for newline" },
{ key: "focus", shortcut: "tab", description: "to toggle focus" },
{ key: "mode", shortcut: "ctrl + m", description: "to cycle modes" },
{ key: "todos", shortcut: "ctrl + t", description: "to view TODO list" },
{ key: "quit", shortcut: "ctrl + c", description: "to quit" },
]
/**
* Create a help trigger for ? shortcuts menu.
*
* This trigger activates when the user types ? at the start of a line,
* and displays a menu of available keyboard shortcuts.
*
* @returns AutocompleteTrigger for help shortcuts
*/
export function createHelpTrigger(): AutocompleteTrigger<HelpShortcutResult> {
return {
id: "help",
triggerChar: "?",
position: "line-start",
consumeTrigger: true,
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with ? (after optional whitespace)
const trimmed = lineText.trimStart()
if (!trimmed.startsWith("?")) {
return null
}
// Extract query after ?
const query = trimmed.substring(1)
// Close picker if query contains space
if (query.includes(" ")) {
return null
}
// Calculate trigger index (position of ? in original line)
const triggerIndex = lineText.length - trimmed.length
return { query, triggerIndex }
},
search: (query: string): HelpShortcutResult[] => {
if (query.length === 0) {
// Show all shortcuts when just "?" is typed
return HELP_SHORTCUTS
}
// Filter shortcuts based on query
const lowerQuery = query.toLowerCase()
return HELP_SHORTCUTS.filter(
(item) =>
item.shortcut.toLowerCase().includes(lowerQuery) ||
item.description.toLowerCase().includes(lowerQuery),
)
},
renderItem: (item: HelpShortcutResult, isSelected: boolean) => {
return (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
<Text bold color={isSelected ? "cyan" : "yellow"}>
{item.shortcut}
</Text>
<Text> {item.description}</Text>
</Text>
</Box>
)
},
getReplacementText: (item: HelpShortcutResult, _lineText: string, _triggerIndex: number): string => {
// When a shortcut is selected, replace with the trigger character
// For action shortcuts (tab, ctrl+c, shift+enter, ctrl+t), just clear the input
if (["newline", "focus", "quit", "todos"].includes(item.key)) {
return ""
}
// For trigger shortcuts (/, @, !), insert the trigger character
return item.shortcut
},
emptyMessage: "No matching shortcuts",
debounceMs: 0, // No debounce needed for static list
}
}

View file

@ -1,193 +0,0 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* History result type.
* Extends AutocompleteItem with task history properties.
*/
export interface HistoryResult extends AutocompleteItem {
/** Task ID */
id: string
/** Task prompt/description */
task: string
/** Timestamp when task was created */
ts: number
/** Total cost of the task */
totalCost?: number
/** Workspace path where task was run */
workspace?: string
/** Mode the task was run in */
mode?: string
/** Task status */
status?: "active" | "completed" | "delegated"
}
/**
* Props for creating a history trigger
*/
export interface HistoryTriggerConfig {
/**
* Get all available history items for filtering.
* Items are filtered locally using fuzzy search.
*/
getHistory: () => HistoryResult[]
/**
* Callback when a history item is selected.
* Used to resume the task.
*/
onSelect?: (item: HistoryResult) => void
/**
* Maximum number of results to show.
* @default 15
*/
maxResults?: number
}
/**
* Format a timestamp as a relative time string
*/
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 maximum length with ellipsis
*/
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text
}
return text.substring(0, maxLength - 1) + "…"
}
/**
* Create a history trigger for # task history.
*
* This trigger activates when the user types # at the start of a line,
* and allows selecting from task history with local fuzzy filtering.
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for history
*/
export function createHistoryTrigger(config: HistoryTriggerConfig): AutocompleteTrigger<HistoryResult> {
const { getHistory, maxResults = 15 } = config
return {
id: "history",
triggerChar: "#",
position: "line-start",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with # (after optional whitespace)
const trimmed = lineText.trimStart()
if (!trimmed.startsWith("#")) {
return null
}
// Extract query after #
const query = trimmed.substring(1)
// Calculate trigger index (position of # in original line)
const triggerIndex = lineText.length - trimmed.length
return { query, triggerIndex }
},
search: (query: string): HistoryResult[] => {
const allHistory = getHistory()
if (query.length === 0) {
// Show most recent items when just "#" is typed (sorted by timestamp, newest first)
return allHistory.sort((a, b) => b.ts - a.ts).slice(0, maxResults)
}
// Fuzzy search by task description
const results = fuzzysort.go(query, allHistory, {
key: "task",
limit: maxResults,
threshold: -10000, // Be lenient with matching
})
return results.map((result) => result.obj)
},
renderItem: (item: HistoryResult, isSelected: boolean) => {
// Status indicator
const statusIcon = item.status === "completed" ? "✓" : item.status === "active" ? "●" : "○"
const statusColor = item.status === "completed" ? "green" : item.status === "active" ? "yellow" : "gray"
// Mode indicator (if available)
const modeText = item.mode ? ` [${item.mode}]` : ""
// Time ago
const timeAgo = formatRelativeTime(item.ts)
// Truncate task to fit in picker
const truncatedTask = truncate(item.task.replace(/\n/g, " "), 50)
return (
<Box paddingLeft={2} flexDirection="row">
<Text color={isSelected ? "cyan" : undefined}>
<Text color={statusColor}>{statusIcon}</Text> {truncatedTask}
<Text dimColor>{modeText}</Text>
<Text dimColor> {timeAgo}</Text>
</Text>
</Box>
)
},
getReplacementText: (_item: HistoryResult, _lineText: string, _triggerIndex: number): string => {
// Return empty string - we don't want to insert any text
// The actual task resumption is handled via the onSelect callback
return ""
},
emptyMessage: "No task history found",
debounceMs: 100,
}
}
/**
* Convert HistoryItem from @roo-code/types to HistoryResult.
* Use this to adapt history items from the store to the trigger's expected type.
*/
export function toHistoryResult(item: {
id: string
task: string
ts: number
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
}): HistoryResult {
return {
key: item.id, // Use task ID as the unique key
id: item.id,
task: item.task,
ts: item.ts,
totalCost: item.totalCost,
workspace: item.workspace,
mode: item.mode,
status: item.status,
}
}

View file

@ -1,109 +0,0 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
export interface ModeResult extends AutocompleteItem {
slug: string
name: string
description?: string
icon?: string
}
export interface ModeTriggerConfig {
getModes: () => ModeResult[]
maxResults?: number
}
/**
* Create a mode trigger for ! mode switching.
*
* This trigger activates when the user types ! at the start of a line,
* and allows selecting modes with local fuzzy filtering.
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for mode switching
*/
export function createModeTrigger(config: ModeTriggerConfig): AutocompleteTrigger<ModeResult> {
const { getModes, maxResults = 20 } = config
return {
id: "mode",
triggerChar: "!",
position: "line-start",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with ! (after optional whitespace)
const trimmed = lineText.trimStart()
if (!trimmed.startsWith("!")) {
return null
}
// Extract query after !
const query = trimmed.substring(1)
// Close picker if query contains space (mode selection complete)
if (query.includes(" ")) {
return null
}
// Calculate trigger index (position of ! in original line)
const triggerIndex = lineText.length - trimmed.length
return { query, triggerIndex }
},
search: (query: string): ModeResult[] => {
const allModes = getModes()
if (query.length === 0) {
// Show all modes when just "!" is typed
return allModes.slice(0, maxResults)
}
// Fuzzy search by mode name and slug
const results = fuzzysort.go(query, allModes, {
keys: ["name", "slug"],
limit: maxResults,
threshold: -10000, // Be lenient with matching
})
return results.map((result) => result.obj)
},
renderItem: (item: ModeResult, isSelected: boolean) => {
return (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
{item.name}
{item.description && <Text dimColor> - {item.description}</Text>}
</Text>
</Box>
)
},
getReplacementText: (_item: ModeResult, _lineText: string, _triggerIndex: number): string => {
// Replace the entire input with just a space (mode will be switched via message)
// This clears the picker trigger from the input
return ""
},
emptyMessage: "No matching modes found",
debounceMs: 150,
}
}
/**
* Convert external mode data to ModeTriggerResult.
* Use this to adapt modes from the store to the trigger's expected type.
*/
export function toModeResult(mode: { slug: string; name: string; description?: string; icon?: string }): ModeResult {
return {
key: mode.slug,
slug: mode.slug,
name: mode.name,
description: mode.description,
icon: mode.icon,
}
}

View file

@ -1,129 +0,0 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import { GlobalCommandAction } from "@/lib/utils/commands.js"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
export interface SlashCommandResult extends AutocompleteItem {
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
/** Action to trigger for CLI global commands (e.g., clearTask for /new) */
action?: GlobalCommandAction
}
export interface SlashCommandTriggerConfig {
getCommands: () => SlashCommandResult[]
maxResults?: number
}
/**
* Create a slash command trigger for / commands.
*
* This trigger activates when the user types / at the start of a line,
* and allows selecting commands with local fuzzy filtering.
*
* @param config - Configuration for the trigger
* @returns AutocompleteTrigger for slash commands
*/
export function createSlashCommandTrigger(config: SlashCommandTriggerConfig): AutocompleteTrigger<SlashCommandResult> {
const { getCommands, maxResults = 20 } = config
return {
id: "slash-command",
triggerChar: "/",
position: "line-start",
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
// Check if line starts with / (after optional whitespace)
const trimmed = lineText.trimStart()
if (!trimmed.startsWith("/")) {
return null
}
// Extract query after /
const query = trimmed.substring(1)
// Close picker if query contains space (command complete)
if (query.includes(" ")) {
return null
}
// Calculate trigger index (position of / in original line)
const triggerIndex = lineText.length - trimmed.length
return { query, triggerIndex }
},
search: (query: string): SlashCommandResult[] => {
const allCommands = getCommands()
if (query.length === 0) {
// Show all commands when just "/" is typed
return allCommands.slice(0, maxResults)
}
// Fuzzy search by command name
const results = fuzzysort.go(query, allCommands, {
key: "name",
limit: maxResults,
threshold: -10000, // Be lenient with matching
})
return results.map((result) => result.obj)
},
renderItem: (item: SlashCommandResult, isSelected: boolean) => {
// Source indicator icons:
// ⚙️ for action commands (CLI global), ⚡ built-in, 📁 project, 🌐 global (content)
const sourceIcon = item.action
? "⚙️"
: item.source === "built-in"
? "⚡"
: item.source === "project"
? "📁"
: "🌐"
return (
<Box paddingLeft={2}>
<Text color={isSelected ? "cyan" : undefined}>
{sourceIcon} /{item.name}
{item.description && <Text dimColor> - {item.description}</Text>}
</Text>
</Box>
)
},
getReplacementText: (item: SlashCommandResult, lineText: string, triggerIndex: number): string => {
const beforeSlash = lineText.substring(0, triggerIndex)
return `${beforeSlash}/${item.name} `
},
emptyMessage: "No matching commands found",
debounceMs: 150,
}
}
/**
* Convert external command data to SlashCommandResult.
* Use this to adapt commands from the store to the trigger's expected type.
*/
export function toSlashCommandResult(command: {
name: string
description?: string
argumentHint?: string
source: "global" | "project" | "built-in"
action?: string
}): SlashCommandResult {
return {
key: command.name,
name: command.name,
description: command.description,
argumentHint: command.argumentHint,
source: command.source,
action: command.action as GlobalCommandAction | undefined,
}
}

View file

@ -1,270 +0,0 @@
import { render } from "ink-testing-library"
import { createFileTrigger, toFileResult, type FileResult } from "../FileTrigger.js"
describe("FileTrigger", () => {
describe("toFileResult", () => {
it("should convert FileSearchResult to FileResult with key", () => {
const input = { path: "src/test.ts", type: "file" as const }
const result = toFileResult(input)
expect(result).toEqual({
key: "src/test.ts",
path: "src/test.ts",
type: "file",
label: undefined,
})
})
it("should include label if provided", () => {
const input = { path: "src/", type: "folder" as const, label: "Source" }
const result = toFileResult(input)
expect(result).toEqual({
key: "src/",
path: "src/",
type: "folder",
label: "Source",
})
})
})
describe("detectTrigger", () => {
const onSearch = vi.fn()
const getResults = (): FileResult[] => []
const trigger = createFileTrigger({ onSearch, getResults })
it("should detect @ trigger with query", () => {
const result = trigger.detectTrigger("hello @test")
expect(result).toEqual({
query: "test",
triggerIndex: 6,
})
})
it("should detect @ trigger at start of line", () => {
const result = trigger.detectTrigger("@fil")
expect(result).toEqual({ query: "fil", triggerIndex: 0 })
})
it("should return null when no @ present", () => {
const result = trigger.detectTrigger("hello world")
expect(result).toBeNull()
})
it("should return null when query contains space", () => {
const result = trigger.detectTrigger("hello @test file")
expect(result).toBeNull()
})
it("should return null when @ followed by space", () => {
const result = trigger.detectTrigger("@ ")
expect(result).toBeNull()
})
it("should detect @ trigger even with empty query", () => {
const result = trigger.detectTrigger("hello @")
expect(result).toEqual({
query: "",
triggerIndex: 6,
})
})
it("should detect @ even without text after it", () => {
const result = trigger.detectTrigger("@")
expect(result).toEqual({ query: "", triggerIndex: 0 })
})
it("should find last @ in line", () => {
const result = trigger.detectTrigger("email@test.com @file")
expect(result).toEqual({
query: "file",
triggerIndex: 15,
})
})
})
describe("getReplacementText", () => {
const onSearch = vi.fn()
const getResults = (): FileResult[] => []
const trigger = createFileTrigger({ onSearch, getResults })
it("should replace @ trigger with file path", () => {
const item: FileResult = { key: "src/test.ts", path: "src/test.ts", type: "file" }
const result = trigger.getReplacementText(item, "hello @tes", 6)
expect(result).toBe("hello @/src/test.ts ")
})
it("should preserve text before @", () => {
const item: FileResult = { key: "config.json", path: "config.json", type: "file" }
const result = trigger.getReplacementText(item, "check @co", 6)
expect(result).toBe("check @/config.json ")
})
it("should generate correct replacement text for folders", () => {
const item = toFileResult({ path: "src/components", type: "folder" })
const lineText = "@comp"
const replacement = trigger.getReplacementText(item, lineText, 0)
expect(replacement).toBe("@/src/components ")
})
it("should preserve full path in replacement text", () => {
const item = toFileResult({
path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx",
type: "file",
})
const lineText = "Fix @Pick"
const replacement = trigger.getReplacementText(item, lineText, 4)
// Verify the full path is included without truncation
expect(replacement).toBe("Fix @/apps/cli/src/ui/components/autocomplete/PickerSelect.tsx ")
// Verify last character 'x' is present
expect(replacement).toContain("PickerSelect.tsx ")
expect(replacement.trim().endsWith(".tsx")).toBe(true)
})
})
describe("search", () => {
it("should call onSearch and return empty array immediately (async pattern)", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [{ key: "test.ts", path: "test.ts", type: "file" }]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.search("test")
// search() should trigger the API call
expect(onSearch).toHaveBeenCalledWith("test")
// search() should return empty immediately for async sources
// (actual results come via refreshResults when API responds)
expect(result).toEqual([])
// getResults should NOT be called by search() - that's the async fix
expect(getResults).not.toHaveBeenCalled()
})
it("should return empty array when no results", () => {
const onSearch = vi.fn()
const getResults = vi.fn(() => [])
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.search("test")
expect(result).toEqual([])
})
})
describe("refreshResults", () => {
it("should call getResults and return current results", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [{ key: "test.ts", path: "test.ts", type: "file" }]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.refreshResults!("test")
// refreshResults should call getResults (not onSearch)
expect(getResults).toHaveBeenCalled()
expect(onSearch).not.toHaveBeenCalled()
expect(result).toEqual(mockResults)
})
it("should sort results by fuzzy match score (best matches first)", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [
{ key: "src/components/Button.tsx", path: "src/components/Button.tsx", type: "file" },
{ key: "app.ts", path: "app.ts", type: "file" },
{ key: "src/app.tsx", path: "src/app.tsx", type: "file" },
{ key: "tests/app.test.ts", path: "tests/app.test.ts", type: "file" },
]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.refreshResults!("app") as FileResult[]
// Results should be sorted with best matches first
// "app.ts" should rank higher than "src/app.tsx" or "tests/app.test.ts"
expect(result[0]?.path).toBe("app.ts")
})
it("should filter out results that don't match well", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [
{ key: "src/test.ts", path: "src/test.ts", type: "file" },
{ key: "config.json", path: "config.json", type: "file" },
]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.refreshResults!("xyz") as FileResult[]
// Results that don't match well are filtered out by fuzzysort
expect(result.length).toBeLessThan(mockResults.length)
})
it("should return results sorted with partial matches", () => {
const onSearch = vi.fn()
const mockResults: FileResult[] = [
{ key: "src/test.ts", path: "src/test.ts", type: "file" },
{ key: "tests/unit.ts", path: "tests/unit.ts", type: "file" },
{ key: "package.json", path: "package.json", type: "file" },
]
const getResults = vi.fn(() => mockResults)
const trigger = createFileTrigger({ onSearch, getResults })
const result = trigger.refreshResults!("test") as FileResult[]
// Should return files that match "test"
expect(result.length).toBeGreaterThan(0)
// All returned results should contain "test" in their path
result.forEach((r: FileResult) => {
expect(r.path.toLowerCase()).toContain("test")
})
})
})
describe("renderItem", () => {
const onSearch = vi.fn()
const getResults = (): FileResult[] => []
const trigger = createFileTrigger({ onSearch, getResults })
it("should render file items correctly", () => {
const item = toFileResult({ path: "src/index.ts", type: "file" })
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
// Verify the path is present in the rendered output
expect(lastFrame()).toContain("src/index.ts")
})
it("should render folder items correctly", () => {
const item = toFileResult({ path: "src/components", type: "folder" })
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
// Verify the path is present in the rendered output
expect(lastFrame()).toContain("src/components")
})
it("should render full path without truncation in UI", () => {
const item = toFileResult({
path: "apps/cli/src/ui/components/autocomplete/PickerSelect.tsx",
type: "file",
})
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const output = lastFrame()
// Verify the full path is rendered without truncation
expect(output).toContain("PickerSelect.tsx")
// Verify the last character 'x' is present
expect(output).toContain("x")
// Verify no truncation occurred
expect(output).not.toMatch(/PickerSelect\.ts[^x]/)
})
})
})

View file

@ -1,169 +0,0 @@
import { render } from "ink-testing-library"
import { createHelpTrigger, type HelpShortcutResult } from "../HelpTrigger.js"
describe("HelpTrigger", () => {
describe("createHelpTrigger", () => {
it("should detect ? trigger at line start", () => {
const trigger = createHelpTrigger()
const result = trigger.detectTrigger("?")
expect(result).toEqual({ query: "", triggerIndex: 0 })
})
it("should detect ? trigger with query", () => {
const trigger = createHelpTrigger()
const result = trigger.detectTrigger("?slash")
expect(result).toEqual({ query: "slash", triggerIndex: 0 })
})
it("should detect ? trigger after whitespace", () => {
const trigger = createHelpTrigger()
const result = trigger.detectTrigger(" ?")
expect(result).toEqual({ query: "", triggerIndex: 2 })
})
it("should not detect ? in middle of text", () => {
const trigger = createHelpTrigger()
// The trigger position is "line-start", so it should only match at start
const result = trigger.detectTrigger("some text ?")
expect(result).toBeNull()
})
it("should not detect ? followed by space", () => {
const trigger = createHelpTrigger()
const result = trigger.detectTrigger("? ")
expect(result).toBeNull()
})
it("should return all shortcuts when query is empty", () => {
const trigger = createHelpTrigger()
const results = trigger.search("") as HelpShortcutResult[]
expect(results.length).toBe(9)
expect(results.map((r) => r.shortcut)).toContain("/")
expect(results.map((r) => r.shortcut)).toContain("@")
expect(results.map((r) => r.shortcut)).toContain("!")
expect(results.map((r) => r.shortcut)).toContain("#")
expect(results.map((r) => r.shortcut)).toContain("shift + ⏎")
expect(results.map((r) => r.shortcut)).toContain("tab")
expect(results.map((r) => r.shortcut)).toContain("ctrl + m")
expect(results.map((r) => r.shortcut)).toContain("ctrl + c")
expect(results.map((r) => r.shortcut)).toContain("ctrl + t")
})
it("should include ctrl+t shortcut for TODO list", () => {
const trigger = createHelpTrigger()
const results = trigger.search("todo") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("ctrl + t")
expect(results[0]?.description).toContain("TODO")
})
it("should clear input for todos action shortcut", () => {
const trigger = createHelpTrigger()
const todosItem: HelpShortcutResult = {
key: "todos",
shortcut: "ctrl + t",
description: "to view TODO list",
}
const replacement = trigger.getReplacementText(todosItem, "?todo", 0)
expect(replacement).toBe("")
})
it("should filter shortcuts by shortcut character", () => {
const trigger = createHelpTrigger()
const results = trigger.search("/") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("/")
})
it("should filter shortcuts by description", () => {
const trigger = createHelpTrigger()
const results = trigger.search("file") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("@")
expect(results[0]?.description).toContain("file")
})
it("should filter case-insensitively", () => {
const trigger = createHelpTrigger()
const results = trigger.search("QUIT") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("ctrl + c")
})
it("should return empty array for non-matching query", () => {
const trigger = createHelpTrigger()
const results = trigger.search("xyz") as HelpShortcutResult[]
expect(results.length).toBe(0)
})
it("should generate replacement text for trigger shortcuts", () => {
const trigger = createHelpTrigger()
const slashItem: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const replacement = trigger.getReplacementText(slashItem, "?", 0)
expect(replacement).toBe("/")
})
it("should clear input for action shortcuts", () => {
const trigger = createHelpTrigger()
const tabItem: HelpShortcutResult = { key: "focus", shortcut: "tab", description: "to toggle focus" }
const replacement = trigger.getReplacementText(tabItem, "?tab", 0)
expect(replacement).toBe("")
})
it("should render shortcut items correctly", () => {
const trigger = createHelpTrigger()
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const output = lastFrame()
expect(output).toContain("/")
expect(output).toContain("for commands")
})
it("should render selected items with different styling", () => {
const trigger = createHelpTrigger()
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement)
// Both should contain the content
expect(unselectedFrame()).toContain("/")
expect(selectedFrame()).toContain("/")
})
it("should have correct trigger configuration", () => {
const trigger = createHelpTrigger()
expect(trigger.id).toBe("help")
expect(trigger.triggerChar).toBe("?")
expect(trigger.position).toBe("line-start")
expect(trigger.emptyMessage).toBe("No matching shortcuts")
expect(trigger.debounceMs).toBe(0)
})
it("should have consumeTrigger set to true", () => {
const trigger = createHelpTrigger()
// The ? character should be consumed (not inserted into input)
// when the help menu is triggered
expect(trigger.consumeTrigger).toBe(true)
})
})
})

View file

@ -1,275 +0,0 @@
import { render } from "ink-testing-library"
import { createHistoryTrigger, toHistoryResult, type HistoryResult } from "../HistoryTrigger.js"
const mockHistoryItems: HistoryResult[] = [
{
key: "task-1",
id: "task-1",
task: "Fix the login bug in the auth module",
ts: Date.now() - 1000 * 60 * 30, // 30 minutes ago
mode: "code",
status: "completed",
workspace: "/projects/my-app",
},
{
key: "task-2",
id: "task-2",
task: "Add unit tests for the user service",
ts: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
mode: "test",
status: "active",
workspace: "/projects/my-app",
},
{
key: "task-3",
id: "task-3",
task: "Refactor the database queries for better performance",
ts: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
mode: "architect",
status: "delegated",
workspace: "/projects/other-app",
},
]
describe("HistoryTrigger", () => {
describe("createHistoryTrigger", () => {
it("should detect # trigger at line start", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const result = trigger.detectTrigger("#")
expect(result).toEqual({ query: "", triggerIndex: 0 })
})
it("should detect # trigger with query", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const result = trigger.detectTrigger("#login")
expect(result).toEqual({ query: "login", triggerIndex: 0 })
})
it("should detect # trigger after whitespace", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const result = trigger.detectTrigger(" #")
expect(result).toEqual({ query: "", triggerIndex: 2 })
})
it("should detect # trigger with query after whitespace", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const result = trigger.detectTrigger(" #fix")
expect(result).toEqual({ query: "fix", triggerIndex: 2 })
})
it("should not detect # in middle of text", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
// The trigger position is "line-start", so it should only match at start
const result = trigger.detectTrigger("some text #")
expect(result).toBeNull()
})
it("should return all history items when query is empty, sorted by timestamp", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const results = trigger.search("") as HistoryResult[]
// Should return all 3 items
expect(results.length).toBe(3)
// Should be sorted by timestamp (newest first)
expect(results[0]?.id).toBe("task-1") // 30 mins ago
expect(results[1]?.id).toBe("task-2") // 2 hours ago
expect(results[2]?.id).toBe("task-3") // 1 day ago
})
it("should filter history items by fuzzy search on task", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const results = trigger.search("login") as HistoryResult[]
expect(results.length).toBe(1)
expect(results[0]?.id).toBe("task-1")
expect(results[0]?.task).toContain("login")
})
it("should handle partial matching", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
// Fuzzy search for "unit" should match "Add unit tests for the user service"
const results = trigger.search("unit") as HistoryResult[]
expect(results.length).toBe(1)
expect(results[0]?.id).toBe("task-2")
})
it("should return empty array for non-matching query", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const results = trigger.search("xyznonexistent") as HistoryResult[]
expect(results.length).toBe(0)
})
it("should respect maxResults limit", () => {
const manyItems: HistoryResult[] = Array.from({ length: 20 }, (_, i) => ({
key: `task-${i}`,
id: `task-${i}`,
task: `Task number ${i}`,
ts: Date.now() - i * 1000 * 60,
mode: "code",
}))
const trigger = createHistoryTrigger({
getHistory: () => manyItems,
maxResults: 5,
})
const results = trigger.search("") as HistoryResult[]
expect(results.length).toBe(5)
})
it("should use default maxResults of 15", () => {
const manyItems: HistoryResult[] = Array.from({ length: 20 }, (_, i) => ({
key: `task-${i}`,
id: `task-${i}`,
task: `Task number ${i}`,
ts: Date.now() - i * 1000 * 60,
mode: "code",
}))
const trigger = createHistoryTrigger({
getHistory: () => manyItems,
})
const results = trigger.search("") as HistoryResult[]
expect(results.length).toBe(15)
})
it("should return empty string for replacement text", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const item = mockHistoryItems[0]!
const replacement = trigger.getReplacementText(item, "#login", 0)
expect(replacement).toBe("")
})
it("should render history items correctly", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const item = mockHistoryItems[0]!
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const output = lastFrame()
// Should contain the task (possibly truncated)
expect(output).toContain("login")
// Should contain mode indicator
expect(output).toContain("[code]")
// Should contain status indicator (✓ for completed)
expect(output).toContain("✓")
})
it("should render active status with correct indicator", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const activeItem = mockHistoryItems[1]! // status: "active"
const { lastFrame } = render(trigger.renderItem(activeItem, false) as React.ReactElement)
const output = lastFrame()
// Should contain the active status indicator (●)
expect(output).toContain("●")
})
it("should render delegated status with correct indicator", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const delegatedItem = mockHistoryItems[2]! // status: "delegated"
const { lastFrame } = render(trigger.renderItem(delegatedItem, false) as React.ReactElement)
const output = lastFrame()
// Should contain the delegated status indicator (○)
expect(output).toContain("○")
})
it("should render selected items with different styling", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
const item = mockHistoryItems[0]!
const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement)
// Both should contain the task content
expect(unselectedFrame()).toContain("login")
expect(selectedFrame()).toContain("login")
})
it("should have correct trigger configuration", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
expect(trigger.id).toBe("history")
expect(trigger.triggerChar).toBe("#")
expect(trigger.position).toBe("line-start")
expect(trigger.emptyMessage).toBe("No task history found")
expect(trigger.debounceMs).toBe(100)
})
it("should not have consumeTrigger set (# character appears in input)", () => {
const trigger = createHistoryTrigger({ getHistory: () => mockHistoryItems })
// The # character should remain in the input like other triggers
expect(trigger.consumeTrigger).toBeUndefined()
})
it("should call getHistory when searching", () => {
const getHistoryMock = vi.fn(() => mockHistoryItems)
const trigger = createHistoryTrigger({ getHistory: getHistoryMock })
trigger.search("")
expect(getHistoryMock).toHaveBeenCalled()
trigger.search("test")
expect(getHistoryMock).toHaveBeenCalledTimes(2)
})
})
describe("toHistoryResult", () => {
it("should convert history item to HistoryResult", () => {
const item = {
id: "test-task-1",
task: "Test task description",
ts: 1704067200000,
totalCost: 0.05,
workspace: "/projects/test",
mode: "code",
status: "completed" as const,
}
const result = toHistoryResult(item)
expect(result.key).toBe("test-task-1") // key should be the task ID
expect(result.id).toBe("test-task-1")
expect(result.task).toBe("Test task description")
expect(result.ts).toBe(1704067200000)
expect(result.totalCost).toBe(0.05)
expect(result.workspace).toBe("/projects/test")
expect(result.mode).toBe("code")
expect(result.status).toBe("completed")
})
it("should handle optional fields", () => {
const minimalItem = {
id: "minimal-task",
task: "Minimal task",
ts: 1704067200000,
}
const result = toHistoryResult(minimalItem)
expect(result.key).toBe("minimal-task")
expect(result.id).toBe("minimal-task")
expect(result.task).toBe("Minimal task")
expect(result.ts).toBe(1704067200000)
expect(result.totalCost).toBeUndefined()
expect(result.workspace).toBeUndefined()
expect(result.mode).toBeUndefined()
expect(result.status).toBeUndefined()
})
})
})

View file

@ -1,160 +0,0 @@
import { type ModeResult, createModeTrigger, toModeResult } from "../ModeTrigger.js"
describe("ModeTrigger", () => {
const testModes: ModeResult[] = [
{ key: "code", slug: "code", name: "Code", description: "Write and modify code" },
{ key: "architect", slug: "architect", name: "Architect", description: "Plan and design" },
{ key: "debug", slug: "debug", name: "Debug", description: "Troubleshoot issues" },
{ key: "ask", slug: "ask", name: "Ask", description: "Get explanations" },
]
describe("createModeTrigger", () => {
it("should create a trigger with correct configuration", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
expect(trigger.id).toBe("mode")
expect(trigger.triggerChar).toBe("!")
expect(trigger.position).toBe("line-start")
expect(trigger.emptyMessage).toBe("No matching modes found")
expect(trigger.debounceMs).toBe(150)
})
it("should detect trigger at line start", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const result = trigger.detectTrigger("!code")
expect(result).not.toBeNull()
expect(result?.query).toBe("code")
expect(result?.triggerIndex).toBe(0)
})
it("should detect trigger after whitespace", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const result = trigger.detectTrigger(" !architect")
expect(result).not.toBeNull()
expect(result?.query).toBe("architect")
expect(result?.triggerIndex).toBe(2)
})
it("should not detect trigger in middle of text", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const result = trigger.detectTrigger("some text !code")
expect(result).toBeNull()
})
it("should close picker when query contains space", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const result = trigger.detectTrigger("!code something")
expect(result).toBeNull()
})
it("should return all modes when query is empty", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const results = trigger.search("")
expect(results).toEqual(testModes)
})
it("should filter modes by name using fuzzy search", async () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const results = await trigger.search("deb")
expect(results).toHaveLength(1)
expect(results[0]!.slug).toBe("debug")
})
it("should filter modes by slug using fuzzy search", async () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const results = await trigger.search("arch")
expect(results).toHaveLength(1)
expect(results[0]!.slug).toBe("architect")
})
it("should respect maxResults limit", async () => {
const trigger = createModeTrigger({
getModes: () => testModes,
maxResults: 2,
})
const results = await trigger.search("")
expect(results.length).toBeLessThanOrEqual(2)
})
it("should return empty replacement text", () => {
const trigger = createModeTrigger({
getModes: () => testModes,
})
const mode = testModes[0]!
const replacement = trigger.getReplacementText(mode, "!code", 0)
expect(replacement).toBe("")
})
})
describe("toModeResult", () => {
it("should convert mode data to ModeResult", () => {
const modeData = {
slug: "code",
name: "Code",
description: "Write and modify code",
icon: "💻",
}
const result = toModeResult(modeData)
expect(result).toEqual({
key: "code",
slug: "code",
name: "Code",
description: "Write and modify code",
icon: "💻",
})
})
it("should handle mode without description", () => {
const modeData = {
slug: "test",
name: "Test Mode",
}
const result = toModeResult(modeData)
expect(result).toEqual({
key: "test",
slug: "test",
name: "Test Mode",
description: undefined,
icon: undefined,
})
})
})
})

View file

@ -1,156 +0,0 @@
import { type SlashCommandResult, createSlashCommandTrigger, toSlashCommandResult } from "../SlashCommandTrigger.js"
describe("SlashCommandTrigger", () => {
describe("toSlashCommandResult", () => {
it("should convert command to SlashCommandResult with key", () => {
const input = {
name: "test",
description: "A test command",
source: "built-in" as const,
}
const result = toSlashCommandResult(input)
expect(result).toEqual({
key: "test",
name: "test",
description: "A test command",
argumentHint: undefined,
source: "built-in",
})
})
it("should include argumentHint if provided", () => {
const input = {
name: "mode",
description: "Switch mode",
argumentHint: "<mode-name>",
source: "project" as const,
}
const result = toSlashCommandResult(input)
expect(result).toEqual({
key: "mode",
name: "mode",
description: "Switch mode",
argumentHint: "<mode-name>",
source: "project",
})
})
})
describe("detectTrigger", () => {
const getCommands = (): SlashCommandResult[] => []
const trigger = createSlashCommandTrigger({ getCommands })
it("should detect / at line start", () => {
const result = trigger.detectTrigger("/test")
expect(result).toEqual({
query: "test",
triggerIndex: 0,
})
})
it("should detect / with leading whitespace", () => {
const result = trigger.detectTrigger(" /test")
expect(result).toEqual({
query: "test",
triggerIndex: 2,
})
})
it("should return query with empty string for just /", () => {
const result = trigger.detectTrigger("/")
expect(result).toEqual({
query: "",
triggerIndex: 0,
})
})
it("should return null when / not at line start", () => {
const result = trigger.detectTrigger("hello /test")
expect(result).toBeNull()
})
it("should return null when query contains space", () => {
const result = trigger.detectTrigger("/test command")
expect(result).toBeNull()
})
})
describe("getReplacementText", () => {
const getCommands = (): SlashCommandResult[] => []
const trigger = createSlashCommandTrigger({ getCommands })
it("should replace / trigger with command name", () => {
const item: SlashCommandResult = {
key: "test",
name: "test",
source: "built-in",
}
const result = trigger.getReplacementText(item, "/tes", 0)
expect(result).toBe("/test ")
})
it("should preserve leading whitespace", () => {
const item: SlashCommandResult = {
key: "mode",
name: "mode",
source: "project",
}
const result = trigger.getReplacementText(item, " /mo", 2)
expect(result).toBe(" /mode ")
})
})
describe("search", () => {
it("should return all commands when query is empty", async () => {
const mockCommands: SlashCommandResult[] = [
{ key: "test", name: "test", source: "built-in" },
{ key: "mode", name: "mode", source: "project" },
]
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands })
const result = await trigger.search("")
expect(result).toEqual(mockCommands)
})
it("should fuzzy search commands by name", async () => {
const mockCommands: SlashCommandResult[] = [
{ key: "test", name: "test", source: "built-in" },
{ key: "mode", name: "mode", source: "project" },
{ key: "help", name: "help", source: "built-in" },
]
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands })
const result = await trigger.search("mod")
// Should prioritize "mode" since it matches best
expect(result.length).toBeGreaterThan(0)
expect(result[0]?.name).toBe("mode")
})
it("should respect maxResults option", async () => {
const mockCommands: SlashCommandResult[] = Array.from({ length: 30 }, (_, i) => ({
key: `cmd${i}`,
name: `cmd${i}`,
source: "built-in" as const,
}))
const getCommands = vi.fn(() => mockCommands)
const trigger = createSlashCommandTrigger({ getCommands, maxResults: 5 })
const result = await trigger.search("")
expect(result).toHaveLength(5)
})
})
})

View file

@ -1,19 +0,0 @@
export { type FileResult, type FileTriggerConfig, createFileTrigger, toFileResult } from "./FileTrigger.js"
export {
type SlashCommandResult,
type SlashCommandTriggerConfig,
createSlashCommandTrigger,
toSlashCommandResult,
} from "./SlashCommandTrigger.js"
export { type ModeResult, type ModeTriggerConfig, createModeTrigger, toModeResult } from "./ModeTrigger.js"
export { type HelpShortcutResult, createHelpTrigger } from "./HelpTrigger.js"
export {
type HistoryResult,
type HistoryTriggerConfig,
createHistoryTrigger,
toHistoryResult,
} from "./HistoryTrigger.js"

View file

@ -1,154 +0,0 @@
import type { ReactNode } from "react"
/**
* Represents a single autocomplete result item.
* All result types must extend this with a unique key.
*/
export interface AutocompleteItem {
/** Unique identifier for this item */
key: string
}
/**
* Result from trigger detection.
*/
export interface TriggerDetectionResult {
/** The search query extracted from the input */
query: string
/** Position of trigger character in the line */
triggerIndex: number
}
/**
* Configuration for an autocomplete trigger.
* Each trigger defines how to detect, search, and render autocomplete options.
*
* @template T - The type of items this trigger produces
*/
export interface AutocompleteTrigger<T extends AutocompleteItem = AutocompleteItem> {
/**
* Unique identifier for this trigger.
* Used to track which trigger is active.
*/
id: string
/**
* The character(s) that activate this trigger.
* Examples: "@", "/", "#"
*/
triggerChar: string
/**
* Where the trigger must appear to activate.
* - 'anywhere': Can appear anywhere in the line (e.g., @ for file mentions)
* - 'line-start': Must be at start of line, optionally after whitespace (e.g., / for commands)
*/
position: "anywhere" | "line-start"
/**
* Detect if this trigger is active and extract the search query.
* @param lineText - The current line of text
* @returns Detection result with query and position, or null if trigger not active
*/
detectTrigger: (lineText: string) => TriggerDetectionResult | null
/**
* Search/filter results based on query.
* Can be synchronous (local filtering) or asynchronous (API call).
* @param query - The search query
* @returns Array of matching items
*/
search: (query: string) => T[] | Promise<T[]>
/**
* Get current results without triggering a new search.
* Used for refreshing results when async data arrives.
* If not provided, forceRefresh will fall back to search().
* @param query - The search query for filtering
* @returns Array of matching items from current data
*/
refreshResults?: (query: string) => T[] | Promise<T[]>
/**
* Render a single item in the picker dropdown.
* @param item - The item to render
* @param isSelected - Whether this item is currently selected
* @returns React node to render
*/
renderItem: (item: T, isSelected: boolean) => ReactNode
/**
* Generate the replacement text when an item is selected.
* @param item - The selected item
* @param lineText - The current line text
* @param triggerIndex - Position of trigger character in line
* @returns The new line text with selection inserted
*/
getReplacementText: (item: T, lineText: string, triggerIndex: number) => string
/**
* Message to show when no results match.
* @default "No results found"
*/
emptyMessage?: string
/**
* Debounce delay in milliseconds for search.
* @default 150
*/
debounceMs?: number
/**
* Whether the trigger character should be consumed (not shown in input).
* When true, the trigger character is treated as a control character
* that activates the picker but doesn't appear in the text input.
* @default false
*/
consumeTrigger?: boolean
}
/**
* State for the active autocomplete picker.
*/
export interface AutocompletePickerState<T extends AutocompleteItem = AutocompleteItem> {
/** Which trigger is currently active (by id) */
activeTrigger: AutocompleteTrigger<T> | null
/** Current search results */
results: T[]
/** Currently selected index */
selectedIndex: number
/** Whether picker is visible */
isOpen: boolean
/** Loading state for async searches */
isLoading: boolean
/** The detected trigger info */
triggerInfo: TriggerDetectionResult | null
}
/**
* Result from handleInputChange indicating if input should be modified.
*/
export interface InputChangeResult {
/** If set, the input value should be replaced with this value (trigger char consumed) */
consumedValue?: string
}
/**
* Actions returned by the useAutocompletePicker hook.
*/
export interface AutocompletePickerActions<T extends AutocompleteItem> {
/** Handle input value changes - detects triggers and initiates search */
handleInputChange: (value: string, lineText: string) => InputChangeResult
/** Handle item selection - returns the new input value */
handleSelect: (item: T, fullValue: string, lineText: string) => string
/** Close the picker */
handleClose: () => void
/** Update selected index */
handleIndexChange: (index: number) => void
/** Navigate selection up */
navigateUp: () => void
/** Navigate selection down */
navigateDown: () => void
/** Force refresh the current search results (for async data that arrived after initial search) */
forceRefresh: () => void
}

View file

@ -1,411 +0,0 @@
import { useState, useCallback, useRef, useEffect } from "react"
import type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"
const DEFAULT_DEBOUNCE_MS = 150
/**
* Hook that manages autocomplete picker state and logic.
*
* This hook supports two types of triggers:
* 1. **Sync triggers** (e.g., slash commands, modes): `search()` returns results directly
* 2. **Async triggers** (e.g., file search): `search()` triggers an API call and returns `[]`,
* then `forceRefresh()` is called when external data arrives
*
* For async triggers (those with `refreshResults` defined), the hook preserves existing
* results during the loading state to prevent UI flickering.
*
* @template T - The type of autocomplete items
* @param triggers - Array of autocomplete triggers to check
* @returns Picker state and actions
*/
export function useAutocompletePicker<T extends AutocompleteItem>(
triggers: AutocompleteTrigger<T>[],
): [AutocompletePickerState<T>, AutocompletePickerActions<T>] {
const [state, setState] = useState<AutocompletePickerState<T>>({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
// Debounce timer refs for each trigger
const debounceTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
const lastQueriesRef = useRef<Map<string, string>>(new Map())
// Cleanup debounce timers on unmount
useEffect(() => {
return () => {
debounceTimersRef.current.forEach((timer) => clearTimeout(timer))
}
}, [])
/**
* Get the last line from the input value
*/
const getLastLine = useCallback((value: string): string => {
const lines = value.split("\n")
return lines[lines.length - 1] || ""
}, [])
/**
* Get the input value with the trigger character removed.
* Used when a trigger has consumeTrigger: true.
*/
const getConsumedValue = useCallback((value: string, lastLine: string, triggerIndex: number): string => {
const lines = value.split("\n")
const lastLineIndex = lines.length - 1
// Remove the trigger character from the last line
const newLastLine = lastLine.slice(0, triggerIndex) + lastLine.slice(triggerIndex + 1)
lines[lastLineIndex] = newLastLine
return lines.join("\n")
}, [])
/**
* Handle input value changes - detects triggers and initiates search.
* Returns an object indicating if the input should be modified (for consumeTrigger).
*/
const handleInputChange = useCallback(
(value: string, lineText?: string): { consumedValue?: string } => {
const lastLine = lineText ?? getLastLine(value)
// Check each trigger for activation
let foundTrigger: AutocompleteTrigger<T> | null = null
let foundTriggerInfo: TriggerDetectionResult | null = null
for (const trigger of triggers) {
const detection = trigger.detectTrigger(lastLine)
if (detection) {
foundTrigger = trigger
foundTriggerInfo = detection
break
}
}
// No trigger found - close picker
if (!foundTrigger || !foundTriggerInfo) {
if (state.isOpen) {
setState((prev) => ({
...prev,
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
}))
}
return {}
}
const { query } = foundTriggerInfo
const debounceMs = foundTrigger.debounceMs ?? DEFAULT_DEBOUNCE_MS
// Clear existing debounce timer for this trigger
const existingTimer = debounceTimersRef.current.get(foundTrigger.id)
if (existingTimer) {
clearTimeout(existingTimer)
}
// Check if query has changed
const lastQuery = lastQueriesRef.current.get(foundTrigger.id)
if (query === lastQuery && state.isOpen && state.activeTrigger?.id === foundTrigger.id) {
// Same query, same trigger - no need to search again
// Still return consumed value if trigger consumes input
if (foundTrigger.consumeTrigger) {
return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) }
}
return {}
}
// Determine if this is an async trigger (has refreshResults for external data)
const isAsyncTrigger = !!foundTrigger.refreshResults
// For async triggers, immediately get cached results filtered by new query
// This prevents the "empty state flash" when reopening picker with different query
let initialResults: T[] = []
if (isAsyncTrigger && foundTrigger.refreshResults) {
try {
const cached = foundTrigger.refreshResults(query)
if (!(cached instanceof Promise)) {
initialResults = cached
}
} catch {
// Ignore errors, will use empty array
}
}
// Set loading state immediately and open picker
// For async triggers with cached results, show them immediately to prevent flickering
// Only set isLoading if we have no cached results to show
const hasResults = initialResults.length > 0
setState((prev) => {
return {
...prev,
activeTrigger: foundTrigger,
// Only show loading state if we have no results to display
isLoading: !hasResults,
isOpen: true,
triggerInfo: foundTriggerInfo,
// Use initial cached results if available, otherwise preserve previous
results: initialResults.length > 0 ? initialResults : prev.results,
selectedIndex: initialResults.length > 0 ? 0 : prev.selectedIndex,
}
})
// Debounce the search
const timer = setTimeout(async () => {
lastQueriesRef.current.set(foundTrigger.id, query)
try {
const results = await foundTrigger.search(query)
setState((prev) => {
// Only update if this is still the active trigger
if (prev.activeTrigger?.id !== foundTrigger.id) {
return prev
}
// For async triggers (those with refreshResults like file search):
// - NEVER update results from search() - it always returns []
// - Keep existing results and stay in loading state
// - Results will be updated via forceRefresh() when async data arrives
if (isAsyncTrigger && results.length === 0) {
// Don't change results or loading state - forceRefresh will handle it
return prev
}
return {
...prev,
results,
selectedIndex: 0,
isOpen: true,
isLoading: false,
}
})
} catch (_error) {
// On error, close picker
setState((prev) => ({
...prev,
results: [],
isOpen: false,
isLoading: false,
}))
}
}, debounceMs)
debounceTimersRef.current.set(foundTrigger.id, timer)
// Return consumed value if trigger consumes input
if (foundTrigger.consumeTrigger) {
return { consumedValue: getConsumedValue(value, lastLine, foundTriggerInfo.triggerIndex) }
}
return {}
},
[triggers, state.isOpen, state.activeTrigger?.id, getLastLine, getConsumedValue],
)
/**
* Handle item selection - returns the new input value with the selection inserted
*/
const handleSelect = useCallback(
(item: T, fullValue: string, lineText?: string): string => {
const { activeTrigger, triggerInfo } = state
if (!activeTrigger || !triggerInfo) {
return fullValue
}
// Get the lines
const lines = fullValue.split("\n")
const lastLineIndex = lines.length - 1
const lastLine = lineText ?? lines[lastLineIndex] ?? ""
// Get replacement text from trigger
const newLastLine = activeTrigger.getReplacementText(item, lastLine, triggerInfo.triggerIndex)
// Replace the last line
lines[lastLineIndex] = newLastLine
const newValue = lines.join("\n")
// Reset state
setState({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
// Clear last query for this trigger
lastQueriesRef.current.delete(activeTrigger.id)
return newValue
},
[state],
)
/**
* Close the picker
*/
const handleClose = useCallback(() => {
// Clear any pending debounce timers
debounceTimersRef.current.forEach((timer) => clearTimeout(timer))
debounceTimersRef.current.clear()
setState({
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
})
}, [])
/**
* Update selected index
*/
const handleIndexChange = useCallback((index: number) => {
setState((prev) => ({
...prev,
selectedIndex: index,
}))
}, [])
/**
* Navigate selection up (with wrap-around)
*/
const navigateUp = useCallback(() => {
setState((prev) => {
if (prev.results.length === 0) return prev
const newIndex = prev.selectedIndex > 0 ? prev.selectedIndex - 1 : prev.results.length - 1
return { ...prev, selectedIndex: newIndex }
})
}, [])
/**
* Navigate selection down (with wrap-around)
*/
const navigateDown = useCallback(() => {
setState((prev) => {
if (prev.results.length === 0) return prev
const newIndex = prev.selectedIndex < prev.results.length - 1 ? prev.selectedIndex + 1 : 0
return { ...prev, selectedIndex: newIndex }
})
}, [])
/**
* Force refresh the current search results.
* This is used when external async data (like file search results) arrives
* after the initial search returned empty.
* Uses refreshResults if available to avoid triggering new API calls.
*
* IMPORTANT: We must find the current trigger from the `triggers` array,
* not use `state.activeTrigger`, because the triggers array is recreated
* with fresh closures when external data changes.
*/
const forceRefresh = useCallback(() => {
const { activeTrigger, triggerInfo } = state
// Only refresh if picker is open and we have an active trigger
if (!activeTrigger || !triggerInfo) {
return
}
// CRITICAL: Find the CURRENT trigger from the triggers array
// The state.activeTrigger holds a stale closure, but triggers array has fresh closures
const currentTrigger = triggers.find((t) => t.id === activeTrigger.id)
if (!currentTrigger) {
return
}
const { query } = triggerInfo
// Use refreshResults if available (doesn't trigger new API call)
// Fall back to search() if refreshResults is not implemented
const refreshFn = currentTrigger.refreshResults ?? currentTrigger.search
try {
const results = refreshFn(query)
// Handle both sync and async search results
if (results instanceof Promise) {
results.then((asyncResults) => {
setState((prev) => {
// Only update if still the same trigger
if (prev.activeTrigger?.id !== activeTrigger.id) {
return prev
}
// Only update if results actually changed to avoid unnecessary re-renders
if (
prev.results.length === asyncResults.length &&
prev.results.every((r, i) => r.key === asyncResults[i]?.key)
) {
return { ...prev, isLoading: false }
}
return {
...prev,
results: asyncResults,
// Preserve selectedIndex if within bounds, otherwise reset to 0
selectedIndex: prev.selectedIndex < asyncResults.length ? prev.selectedIndex : 0,
isLoading: false,
}
})
})
} else {
setState((prev) => {
// Only update if still the same trigger
if (prev.activeTrigger?.id !== activeTrigger.id) {
return prev
}
// Only update if results actually changed to avoid unnecessary re-renders
if (
prev.results.length === results.length &&
prev.results.every((r, i) => r.key === results[i]?.key)
) {
return { ...prev, isLoading: false }
}
return {
...prev,
results,
// Preserve selectedIndex if within bounds, otherwise reset to 0
selectedIndex: prev.selectedIndex < results.length ? prev.selectedIndex : 0,
isLoading: false,
}
})
}
} catch (_error) {
// Silently fail on refresh errors.
}
}, [state, triggers])
const actions: AutocompletePickerActions<T> = {
handleInputChange,
handleSelect,
handleClose,
handleIndexChange,
navigateUp,
navigateDown,
forceRefresh,
}
return [state, actions]
}

View file

@ -1,28 +0,0 @@
import { Box, Text } from "ink"
import { Select } from "@inkjs/ui"
import { OnboardingProviderChoice, ASCII_ROO } from "@/types/index.js"
export interface OnboardingScreenProps {
onSelect: (choice: OnboardingProviderChoice) => void
}
export function OnboardingScreen({ onSelect }: OnboardingScreenProps) {
return (
<Box flexDirection="column" gap={1}>
<Text bold color="cyan">
{ASCII_ROO}
</Text>
<Text dimColor>Welcome! How would you like to connect to an LLM provider?</Text>
<Select
options={[
{ label: "Connect to Roo Code Cloud", value: OnboardingProviderChoice.Roo },
{ label: "Bring your own API key", value: OnboardingProviderChoice.Byok },
]}
onChange={(value: string) => {
onSelect(value as OnboardingProviderChoice)
}}
/>
</Box>
)
}

View file

@ -1 +0,0 @@
export * from "./OnboardingScreen.js"

View file

@ -1,87 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { getToolDisplayName, getToolIconName } from "./utils.js"
const ACTION_LABELS: Record<string, string> = {
launch: "Launch Browser",
click: "Click",
hover: "Hover",
type: "Type Text",
press: "Press Key",
scroll_down: "Scroll Down",
scroll_up: "Scroll Up",
resize: "Resize Window",
close: "Close Browser",
screenshot: "Take Screenshot",
}
export function BrowserTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const action = toolData.action || ""
const url = toolData.url || ""
const coordinate = toolData.coordinate || ""
const content = toolData.content || "" // May contain text for type action.
const actionLabel = ACTION_LABELS[action] || action
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{action && (
<Text color={theme.focusColor} bold>
{" "}
{actionLabel}
</Text>
)}
</Box>
{/* Action details */}
<Box flexDirection="column" marginLeft={2}>
{/* URL for launch action */}
{url && (
<Box>
<Text color={theme.dimText}>url: </Text>
<Text color={theme.text} underline>
{url}
</Text>
</Box>
)}
{/* Coordinates for click/hover actions */}
{coordinate && (
<Box>
<Text color={theme.dimText}>at: </Text>
<Text color={theme.warningColor}>{coordinate}</Text>
</Box>
)}
{/* Text content for type action */}
{content && action === "type" && (
<Box>
<Text color={theme.dimText}>text: </Text>
<Text color={theme.text}>"{content}"</Text>
</Box>
)}
{/* Key for press action */}
{content && action === "press" && (
<Box>
<Text color={theme.dimText}>key: </Text>
<Text color={theme.successColor}>{content}</Text>
</Box>
)}
</Box>
</Box>
)
}

View file

@ -1,50 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolIconName } from "./utils.js"
const MAX_OUTPUT_LINES = 10
export function CommandTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const command = toolData.command || ""
const output = toolData.output ? sanitizeContent(toolData.output) : ""
const content = toolData.content ? sanitizeContent(toolData.content) : ""
const displayOutput = output || content
const { text: previewOutput, truncated, hiddenLines } = truncateText(displayOutput, MAX_OUTPUT_LINES)
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Box>
<Icon name={iconName} color={theme.toolHeader} />
{command && (
<Box marginLeft={1}>
<Text color={theme.successColor}>$ </Text>
<Text color={theme.text} bold>
{command}
</Text>
</Box>
)}
</Box>
{previewOutput && (
<Box flexDirection="column">
<Box flexDirection="column" borderStyle="single" borderColor={theme.borderColor} paddingX={1}>
{previewOutput.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -1,40 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent } from "./utils.js"
const MAX_CONTENT_LINES = 15
export function CompletionTool({ toolData }: ToolRendererProps) {
const result = toolData.result ? sanitizeContent(toolData.result) : ""
const question = toolData.question ? sanitizeContent(toolData.question) : ""
const content = toolData.content ? sanitizeContent(toolData.content) : ""
const isQuestion = toolData.tool.includes("question") || toolData.tool.includes("Question")
const displayContent = result || question || content
const { text: previewContent, truncated, hiddenLines } = truncateText(displayContent, MAX_CONTENT_LINES)
return previewContent ? (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{isQuestion ? (
<Box flexDirection="column">
<Text color={theme.text}>{previewContent}</Text>
</Box>
) : (
<Box flexDirection="column">
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
)}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
) : null
}

View file

@ -1,131 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
const MAX_PREVIEW_LINES = 12
/**
* Check if content looks like actual file content vs just path info
* File content typically has newlines or is longer than a typical path
*/
function isActualContent(content: string, path: string): boolean {
if (!content) return false
// If content equals path or is just the path, it's not actual content
if (content === path || content.endsWith(path)) return false
// Check if it looks like a plain path (no newlines, starts with / or drive letter)
if (!content.includes("\n") && (content.startsWith("/") || /^[A-Z]:\\/.test(content))) return false
// Has newlines or doesn't look like a path - treat as content
return content.includes("\n") || content.length > 200
}
export function FileReadTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const path = toolData.path || ""
const rawContent = toolData.content ? sanitizeContent(toolData.content) : ""
const isOutsideWorkspace = toolData.isOutsideWorkspace
const isList = toolData.tool.includes("list") || toolData.tool.includes("List")
// Only show content if it's actual file content, not just path info
const content = isActualContent(rawContent, path) ? rawContent : ""
// Handle batch file reads
if (toolData.batchFiles && toolData.batchFiles.length > 0) {
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
<Text color={theme.dimText}> ({toolData.batchFiles.length} files)</Text>
</Box>
{/* File list */}
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{toolData.batchFiles.slice(0, 10).map((file, index) => (
<Box key={index}>
<Text color={theme.text} bold>
{file.path}
</Text>
{file.lineSnippet && <Text color={theme.dimText}> ({file.lineSnippet})</Text>}
{file.isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</Box>
))}
{toolData.batchFiles.length > 10 && (
<Text color={theme.dimText}>... and {toolData.batchFiles.length - 10} more files</Text>
)}
</Box>
</Box>
)
}
// Single file read
const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_PREVIEW_LINES)
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header with path on same line for single file */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{displayName}
</Text>
{path && (
<>
<Text color={theme.dimText}> · </Text>
<Text color={theme.text} bold>
{path}
</Text>
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</>
)}
</Box>
{/* Content preview - only if we have actual file content */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{isList ? (
// Directory listing - show as tree-like structure
<Box flexDirection="column">
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
</Box>
) : (
// File content - show in a box
<Box flexDirection="column">
<Box borderStyle="single" borderColor={theme.borderColor} paddingX={1}>
<Text color={theme.toolText}>{previewContent}</Text>
</Box>
</Box>
)}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -1,165 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js"
const MAX_DIFF_LINES = 15
export function FileWriteTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const path = toolData.path || ""
const diffStats = toolData.diffStats
const diff = toolData.diff ? sanitizeContent(toolData.diff) : ""
const isProtected = toolData.isProtected
const isOutsideWorkspace = toolData.isOutsideWorkspace
const isNewFile = toolData.tool === "newFileCreated" || toolData.tool === "write_to_file"
// Handle batch diff operations
if (toolData.batchDiffs && toolData.batchDiffs.length > 0) {
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
<Text color={theme.dimText}> ({toolData.batchDiffs.length} files)</Text>
</Box>
{/* File list with stats */}
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{toolData.batchDiffs.slice(0, 8).map((file, index) => (
<Box key={index}>
<Text color={theme.text} bold>
{file.path}
</Text>
{file.diffStats && (
<Box marginLeft={1}>
<Text color={theme.successColor}>+{file.diffStats.added}</Text>
<Text color={theme.dimText}> / </Text>
<Text color={theme.errorColor}>-{file.diffStats.removed}</Text>
</Box>
)}
</Box>
))}
{toolData.batchDiffs.length > 8 && (
<Text color={theme.dimText}>... and {toolData.batchDiffs.length - 8} more files</Text>
)}
</Box>
</Box>
)
}
// Single file write
const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES)
const diffHunks = diff ? parseDiff(diff) : []
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
{/* Header row with path on same line */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{displayName}
</Text>
{path && (
<>
<Text color={theme.dimText}> · </Text>
<Text color={theme.text} bold>
{path}
</Text>
</>
)}
{isNewFile && (
<Text color={theme.successColor} bold>
{" "}
NEW
</Text>
)}
{/* Diff stats badge */}
{diffStats && (
<>
<Text color={theme.dimText}> </Text>
<Text color={theme.successColor} bold>
+{diffStats.added}
</Text>
<Text color={theme.dimText}>/</Text>
<Text color={theme.errorColor} bold>
-{diffStats.removed}
</Text>
</>
)}
{/* Warning badges */}
{isProtected && <Text color={theme.errorColor}> 🔒 protected</Text>}
{isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
</Box>
{/* Diff preview */}
{diffHunks.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
{diffHunks.slice(0, 2).map((hunk, hunkIndex) => (
<Box key={hunkIndex} flexDirection="column">
{/* Hunk header */}
<Text color={theme.focusColor} dimColor>
{hunk.header}
</Text>
{/* Diff lines */}
{hunk.lines.slice(0, 8).map((line, lineIndex) => (
<Text
key={lineIndex}
color={
line.type === "added"
? theme.successColor
: line.type === "removed"
? theme.errorColor
: theme.toolText
}>
{line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
{line.content}
</Text>
))}
{hunk.lines.length > 8 && (
<Text color={theme.dimText} dimColor>
... ({hunk.lines.length - 8} more lines in hunk)
</Text>
)}
</Box>
))}
{diffHunks.length > 2 && (
<Text color={theme.dimText} dimColor>
... ({diffHunks.length - 2} more hunks)
</Text>
)}
</Box>
)}
{/* Fallback to raw diff if no hunks parsed */}
{diffHunks.length === 0 && previewDiff && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.toolText}>{previewDiff}</Text>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -1,93 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
const MAX_CONTENT_LINES = 12
export function GenericTool({ toolData, rawContent }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
// Gather all available information
const path = toolData.path
const content = toolData.content ? sanitizeContent(toolData.content) : ""
const reason = toolData.reason ? sanitizeContent(toolData.reason) : ""
const mode = toolData.mode
// Build display content from available fields
let displayContent = content || reason || ""
// If we have no structured content but have raw content, try to parse it
if (!displayContent && rawContent) {
try {
const parsed = JSON.parse(rawContent)
// Extract any content-like fields
displayContent = sanitizeContent(parsed.content || parsed.output || parsed.result || parsed.reason || "")
} catch {
// Use raw content as-is if not JSON
displayContent = sanitizeContent(rawContent)
}
}
const { text: previewContent, truncated, hiddenLines } = truncateText(displayContent, MAX_CONTENT_LINES)
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
</Box>
{/* Path if present */}
{path && (
<Box marginLeft={2}>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text} bold>
{path}
</Text>
{toolData.isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
{toolData.isProtected && <Text color={theme.errorColor}> 🔒 protected</Text>}
</Box>
)}
{/* Mode if present */}
{mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>mode: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Content */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={path || mode ? 1 : 0}>
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -1,28 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { getToolIconName } from "./utils.js"
export function ModeTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const mode = toolData.mode || ""
const isSwitch = toolData.tool.includes("switch") || toolData.tool.includes("Switch")
return (
<Box flexDirection="row" gap={1} paddingX={1} marginBottom={1}>
<Icon name={iconName} color={theme.toolHeader} />
{isSwitch && mode && (
<Box gap={1}>
<Text color={theme.dimText}>Switching to</Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
<Text color={theme.dimText}>mode</Text>
</Box>
)}
</Box>
)
}

View file

@ -1,113 +0,0 @@
import { Box, Text } from "ink"
import * as theme from "../../theme.js"
import { Icon } from "../Icon.js"
import type { ToolRendererProps } from "./types.js"
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
const MAX_RESULT_LINES = 15
export function SearchTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const regex = toolData.regex || ""
const query = toolData.query || ""
const filePattern = toolData.filePattern || ""
const path = toolData.path || ""
const content = toolData.content ? sanitizeContent(toolData.content) : ""
// Parse search results if content looks like results.
const resultLines = content.split("\n").filter((line) => line.trim())
const matchCount = resultLines.length
const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_RESULT_LINES)
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{matchCount > 0 && <Text color={theme.dimText}> ({matchCount} matches)</Text>}
</Box>
{/* Search parameters */}
<Box flexDirection="column" marginLeft={2}>
{/* Regex/Query */}
{regex && (
<Box>
<Text color={theme.dimText}>regex: </Text>
<Text color={theme.warningColor} bold>
{regex}
</Text>
</Box>
)}
{query && (
<Box>
<Text color={theme.dimText}>query: </Text>
<Text color={theme.warningColor} bold>
{query}
</Text>
</Box>
)}
{/* Search scope */}
<Box>
{path && (
<>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text}>{path}</Text>
</>
)}
{filePattern && (
<>
<Text color={theme.dimText}> pattern: </Text>
<Text color={theme.text}>{filePattern}</Text>
</>
)}
</Box>
</Box>
{/* Results */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.dimText} bold>
Results:
</Text>
<Box flexDirection="column" marginTop={0}>
{previewContent.split("\n").map((line, i) => {
// Try to highlight file:line patterns
const match = line.match(/^([^:]+):(\d+):(.*)$/)
if (match) {
const [, file, lineNum, context] = match
return (
<Box key={i}>
<Text color={theme.focusColor}>{file}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.warningColor}>{lineNum}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.toolText}>{context}</Text>
</Box>
)
}
return (
<Text key={i} color={theme.toolText}>
{line}
</Text>
)
})}
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more results)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -1,164 +0,0 @@
import { render } from "ink-testing-library"
import type { ToolRendererProps } from "../types.js"
import { CommandTool } from "../CommandTool.js"
describe("CommandTool", () => {
describe("command display", () => {
it("displays the command when toolData.command is provided", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "npm test",
output: "All tests passed",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
// Command should be displayed with $ prefix
expect(output).toContain("$")
expect(output).toContain("npm test")
})
it("does not display command section when toolData.command is empty", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "",
output: "All tests passed",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
// The output should be displayed but no command line with $
expect(output).toContain("All tests passed")
// Should not have a standalone $ followed by a command
// (just checking the output is present without command)
})
it("does not display command section when toolData.command is undefined", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
output: "All tests passed",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
// The output should be displayed
expect(output).toContain("All tests passed")
})
it("displays command with complex arguments", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: 'git commit -m "fix: resolve issue"',
output: "[main abc123] fix: resolve issue",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
expect(output).toContain("$")
expect(output).toContain('git commit -m "fix: resolve issue"')
})
})
describe("output display", () => {
it("displays output when provided", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "echo hello",
output: "hello",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
expect(output).toContain("hello")
})
it("displays multi-line output", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "ls",
output: "file1.txt\nfile2.txt\nfile3.txt",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
expect(output).toContain("file1.txt")
expect(output).toContain("file2.txt")
expect(output).toContain("file3.txt")
})
it("uses content as fallback when output is not provided", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "ls",
content: "fallback content",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
expect(output).toContain("fallback content")
})
it("truncates output to MAX_OUTPUT_LINES", () => {
// Create output with more than 10 lines (MAX_OUTPUT_LINES = 10)
const longOutput = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n")
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "cat longfile.txt",
output: longOutput,
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
// First 10 lines should be visible
expect(output).toContain("line 1")
expect(output).toContain("line 10")
// Should show truncation indicator
expect(output).toContain("more lines")
})
})
describe("header display", () => {
it("displays terminal icon when rendered", () => {
const props: ToolRendererProps = {
toolData: {
tool: "execute_command",
command: "echo test",
},
}
const { lastFrame } = render(<CommandTool {...props} />)
const output = lastFrame()
// The terminal icon fallback is "$", which also appears before the command
expect(output).toContain("$")
expect(output).toContain("echo test")
})
})
})

View file

@ -1,63 +0,0 @@
/**
* Tool renderer components for CLI TUI
*
* Each tool type has a specialized renderer that optimizes the display
* of its unique data structure.
*/
import type React from "react"
import type { ToolRendererProps } from "./types.js"
import { getToolCategory } from "./types.js"
// Import all renderers
import { FileReadTool } from "./FileReadTool.js"
import { FileWriteTool } from "./FileWriteTool.js"
import { SearchTool } from "./SearchTool.js"
import { CommandTool } from "./CommandTool.js"
import { BrowserTool } from "./BrowserTool.js"
import { ModeTool } from "./ModeTool.js"
import { CompletionTool } from "./CompletionTool.js"
import { GenericTool } from "./GenericTool.js"
// Re-export types
export type { ToolRendererProps } from "./types.js"
export { getToolCategory } from "./types.js"
// Re-export utilities
export * from "./utils.js"
// Re-export individual components for direct usage
export { FileReadTool } from "./FileReadTool.js"
export { FileWriteTool } from "./FileWriteTool.js"
export { SearchTool } from "./SearchTool.js"
export { CommandTool } from "./CommandTool.js"
export { BrowserTool } from "./BrowserTool.js"
export { ModeTool } from "./ModeTool.js"
export { CompletionTool } from "./CompletionTool.js"
export { GenericTool } from "./GenericTool.js"
/**
* Map of tool categories to their renderer components
*/
const CATEGORY_RENDERERS: Record<string, React.FC<ToolRendererProps>> = {
"file-read": FileReadTool,
"file-write": FileWriteTool,
search: SearchTool,
command: CommandTool,
browser: BrowserTool,
mode: ModeTool,
completion: CompletionTool,
other: GenericTool,
}
/**
* Get the appropriate renderer component for a tool
*
* @param toolName - The tool name/identifier
* @returns The renderer component for this tool type
*/
export function getToolRenderer(toolName: string): React.FC<ToolRendererProps> {
const category = getToolCategory(toolName)
return CATEGORY_RENDERERS[category] || GenericTool
}

View file

@ -1,44 +0,0 @@
import type { ToolData } from "../../types.js"
export interface ToolRendererProps {
toolData: ToolData
rawContent?: string
}
export type ToolCategory =
| "file-read"
| "file-write"
| "search"
| "command"
| "browser"
| "mode"
| "completion"
| "other"
export function getToolCategory(toolName: string): ToolCategory {
const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"]
const fileWriteTools = [
"editedExistingFile",
"appliedDiff",
"apply_diff",
"newFileCreated",
"write_to_file",
"writeToFile",
]
const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"]
const commandTools = ["execute_command", "executeCommand"]
const browserTools = ["browser_action", "browserAction"]
const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"]
const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"]
if (fileReadTools.includes(toolName)) return "file-read"
if (fileWriteTools.includes(toolName)) return "file-write"
if (searchTools.includes(toolName)) return "search"
if (commandTools.includes(toolName)) return "command"
if (browserTools.includes(toolName)) return "browser"
if (modeTools.includes(toolName)) return "mode"
if (completionTools.includes(toolName)) return "completion"
return "other"
}

View file

@ -1,220 +0,0 @@
import type { IconName } from "../Icon.js"
/**
* Truncate text and return truncation info
*/
export function truncateText(
text: string,
maxLines: number = 10,
): { text: string; truncated: boolean; totalLines: number; hiddenLines: number } {
const lines = text.split("\n")
const totalLines = lines.length
if (lines.length <= maxLines) {
return { text, truncated: false, totalLines, hiddenLines: 0 }
}
const truncatedText = lines.slice(0, maxLines).join("\n")
return {
text: truncatedText,
truncated: true,
totalLines,
hiddenLines: totalLines - maxLines,
}
}
/**
* Sanitize content for terminal display
* - Replaces tabs with spaces
* - Strips carriage returns
*/
export function sanitizeContent(text: string): string {
return text.replace(/\t/g, " ").replace(/\r/g, "")
}
/**
* Format diff stats as a colored string representation
*/
export function formatDiffStats(stats: { added: number; removed: number }): { added: string; removed: string } {
return {
added: `+${stats.added}`,
removed: `-${stats.removed}`,
}
}
/**
* Get a friendly display name for a tool
*/
export function getToolDisplayName(toolName: string): string {
const displayNames: Record<string, string> = {
// File read operations
readFile: "Read",
read_file: "Read",
skill: "Load Skill",
listFilesTopLevel: "List Files",
listFilesRecursive: "List Files (Recursive)",
list_files: "List Files",
// File write operations
editedExistingFile: "Edit",
appliedDiff: "Diff",
apply_diff: "Diff",
newFileCreated: "Create File",
write_to_file: "Write File",
writeToFile: "Write File",
// Search operations
searchFiles: "Search Files",
search_files: "Search Files",
codebaseSearch: "Codebase Search",
codebase_search: "Codebase Search",
// Command operations
execute_command: "Execute Command",
executeCommand: "Execute Command",
// Browser operations
browser_action: "Browser Action",
browserAction: "Browser Action",
// Mode operations
switchMode: "Switch Mode",
switch_mode: "Switch Mode",
newTask: "New Task",
new_task: "New Task",
finishTask: "Finish Task",
// Completion operations
attempt_completion: "Task Complete",
attemptCompletion: "Task Complete",
ask_followup_question: "Question",
askFollowupQuestion: "Question",
// TODO operations
update_todo_list: "Update TODO List",
updateTodoList: "Update TODO List",
}
return displayNames[toolName] || toolName
}
/**
* Get the IconName for a tool (for use with Icon component)
*/
export function getToolIconName(toolName: string): IconName {
const iconNames: Record<string, IconName> = {
// File read operations
readFile: "file",
read_file: "file",
skill: "file",
listFilesTopLevel: "folder",
listFilesRecursive: "folder",
list_files: "folder",
// File write operations
editedExistingFile: "file-edit",
appliedDiff: "diff",
apply_diff: "diff",
newFileCreated: "file-edit",
write_to_file: "file-edit",
writeToFile: "file-edit",
// Search operations
searchFiles: "search",
search_files: "search",
codebaseSearch: "search",
codebase_search: "search",
// Command operations
execute_command: "terminal",
executeCommand: "terminal",
// Browser operations
browser_action: "browser",
browserAction: "browser",
// Mode operations
switchMode: "switch",
switch_mode: "switch",
newTask: "switch",
new_task: "switch",
finishTask: "check",
// Completion operations
attempt_completion: "check",
attemptCompletion: "check",
ask_followup_question: "question",
askFollowupQuestion: "question",
// TODO operations
update_todo_list: "check",
updateTodoList: "check",
}
return iconNames[toolName] || "gear"
}
/**
* Format a file path for display, optionally with workspace indicator
*/
export function formatPath(path: string, isOutsideWorkspace?: boolean, isProtected?: boolean): string {
let result = path
const badges: string[] = []
if (isOutsideWorkspace) {
badges.push("outside workspace")
}
if (isProtected) {
badges.push("protected")
}
if (badges.length > 0) {
result += ` (${badges.join(", ")})`
}
return result
}
/**
* Parse diff content into structured hunks for rendering
*/
export interface DiffHunk {
header: string
lines: Array<{
type: "context" | "added" | "removed" | "header"
content: string
lineNumber?: number
}>
}
export function parseDiff(diffContent: string): DiffHunk[] {
const hunks: DiffHunk[] = []
const lines = diffContent.split("\n")
let currentHunk: DiffHunk | null = null
for (const line of lines) {
if (line.startsWith("@@")) {
// New hunk header
if (currentHunk) {
hunks.push(currentHunk)
}
currentHunk = { header: line, lines: [] }
} else if (currentHunk) {
if (line.startsWith("+") && !line.startsWith("+++")) {
currentHunk.lines.push({ type: "added", content: line.substring(1) })
} else if (line.startsWith("-") && !line.startsWith("---")) {
currentHunk.lines.push({ type: "removed", content: line.substring(1) })
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({ type: "context", content: line.substring(1) || "" })
}
}
}
if (currentHunk) {
hunks.push(currentHunk)
}
return hunks
}

View file

@ -1,38 +0,0 @@
/**
* TerminalSizeContext - Provides terminal dimensions via React Context
* This ensures only one instance of useTerminalSize exists in the app
*/
import { createContext, useContext, ReactNode } from "react"
import { useTerminalSize as useTerminalSizeHook } from "./useTerminalSize.js"
interface TerminalSizeContextValue {
columns: number
rows: number
}
const TerminalSizeContext = createContext<TerminalSizeContextValue | null>(null)
interface TerminalSizeProviderProps {
children: ReactNode
}
/**
* Provider component that wraps the app and provides terminal size to all children
*/
export function TerminalSizeProvider({ children }: TerminalSizeProviderProps) {
const size = useTerminalSizeHook()
return <TerminalSizeContext.Provider value={size}>{children}</TerminalSizeContext.Provider>
}
/**
* Hook to access terminal size from context
* Must be used within a TerminalSizeProvider
*/
export function useTerminalSize(): TerminalSizeContextValue {
const context = useContext(TerminalSizeContext)
if (!context) {
throw new Error("useTerminalSize must be used within a TerminalSizeProvider")
}
return context
}

View file

@ -1,190 +0,0 @@
import { useToastStore } from "../useToast.js"
describe("useToastStore", () => {
beforeEach(() => {
// Reset the store before each test
useToastStore.setState({ toasts: [] })
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe("initial state", () => {
it("should start with an empty toast queue", () => {
const state = useToastStore.getState()
expect(state.toasts).toEqual([])
})
})
describe("addToast", () => {
it("should add a toast to the queue", () => {
const { addToast } = useToastStore.getState()
const id = addToast("Test message")
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(1)
expect(state.toasts[0]).toMatchObject({
id,
message: "Test message",
type: "info",
duration: 3000,
})
})
it("should add a toast with custom type", () => {
const { addToast } = useToastStore.getState()
const id = addToast("Error message", "error")
const state = useToastStore.getState()
expect(state.toasts[0]).toMatchObject({
id,
message: "Error message",
type: "error",
})
})
it("should add a toast with custom duration", () => {
const { addToast } = useToastStore.getState()
const id = addToast("Custom duration", "info", 5000)
const state = useToastStore.getState()
expect(state.toasts[0]).toMatchObject({
id,
duration: 5000,
})
})
it("should replace existing toast when adding a new one (immediate display)", () => {
const { addToast } = useToastStore.getState()
addToast("First message")
addToast("Second message")
addToast("Third message")
const state = useToastStore.getState()
// New toasts replace existing ones for immediate display
expect(state.toasts).toHaveLength(1)
expect(state.toasts[0]?.message).toBe("Third message")
})
it("should generate unique IDs for each toast", () => {
const { addToast } = useToastStore.getState()
const id1 = addToast("First")
const id2 = addToast("Second")
const id3 = addToast("Third")
expect(id1).not.toBe(id2)
expect(id2).not.toBe(id3)
expect(id1).not.toBe(id3)
})
it("should set createdAt timestamp", () => {
const { addToast } = useToastStore.getState()
const beforeTime = Date.now()
addToast("Timestamped message")
const state = useToastStore.getState()
expect(state.toasts[0]?.createdAt).toBeGreaterThanOrEqual(beforeTime)
expect(state.toasts[0]?.createdAt).toBeLessThanOrEqual(Date.now())
})
it("should support success type", () => {
const { addToast } = useToastStore.getState()
addToast("Success", "success")
const state = useToastStore.getState()
expect(state.toasts[0]?.type).toBe("success")
})
it("should support warning type", () => {
const { addToast } = useToastStore.getState()
addToast("Warning", "warning")
const state = useToastStore.getState()
expect(state.toasts[0]?.type).toBe("warning")
})
})
describe("removeToast", () => {
it("should remove a toast by ID", () => {
const { addToast, removeToast } = useToastStore.getState()
const id = addToast("Only toast")
removeToast(id)
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(0)
})
it("should handle removing non-existent toast gracefully", () => {
const { addToast, removeToast } = useToastStore.getState()
addToast("Only toast")
removeToast("non-existent-id")
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(1)
})
})
describe("clearToasts", () => {
it("should clear all toasts", () => {
const { addToast, clearToasts } = useToastStore.getState()
addToast("First")
addToast("Second")
addToast("Third")
clearToasts()
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(0)
})
it("should handle clearing empty queue", () => {
const { clearToasts } = useToastStore.getState()
clearToasts()
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(0)
})
})
describe("immediate replacement behavior", () => {
it("should show latest toast immediately when multiple are added", () => {
const { addToast } = useToastStore.getState()
addToast("First")
addToast("Second")
const id3 = addToast("Third")
const state = useToastStore.getState()
// Only most recent toast is present
expect(state.toasts).toHaveLength(1)
expect(state.toasts[0]?.id).toBe(id3)
expect(state.toasts[0]?.message).toBe("Third")
})
it("should return empty when toast is removed", () => {
const { addToast, removeToast } = useToastStore.getState()
const id = addToast("Only toast")
removeToast(id)
const state = useToastStore.getState()
expect(state.toasts).toHaveLength(0)
})
})
})

View file

@ -1,22 +0,0 @@
// Export existing hooks
export { TerminalSizeProvider, useTerminalSize } from "./TerminalSizeContext.js"
export { useToast, useToastStore } from "./useToast.js"
export { useInputHistory } from "./useInputHistory.js"
// Export new extracted hooks
export { useFollowupCountdown } from "./useFollowupCountdown.js"
export { useFocusManagement } from "./useFocusManagement.js"
export { useMessageHandlers } from "./useMessageHandlers.js"
export { useExtensionHost } from "./useExtensionHost.js"
export { useTaskSubmit } from "./useTaskSubmit.js"
export { useGlobalInput } from "./useGlobalInput.js"
export { usePickerHandlers } from "./usePickerHandlers.js"
// Export types
export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js"
export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js"
export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js"
export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js"
export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js"
export type { UseGlobalInputOptions } from "./useGlobalInput.js"
export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js"

View file

@ -1,153 +0,0 @@
import { useEffect, useRef, useCallback, useMemo } from "react"
import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { ExtensionHostInterface, ExtensionHostOptions } from "@/agent/index.js"
import { useCLIStore } from "../store.js"
// TODO: Unify with TUIAppProps?
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
onExtensionMessage: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface
}
export interface UseExtensionHostReturn {
isReady: boolean
sendToExtension: ((msg: WebviewMessage) => void) | null
runTask: ((prompt: string) => Promise<void>) | null
cleanup: () => Promise<void>
}
/**
* Hook to manage the extension host lifecycle.
*
* Responsibilities:
* - Initialize the extension host
* - Set up event listeners for messages, task completion, and errors
* - Handle cleanup/disposal
* - Expose methods for sending messages and running tasks
*/
export function useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
user,
provider,
apiKey,
model,
workspacePath,
extensionPath,
nonInteractive,
ephemeral,
debug,
exitOnComplete,
onExtensionMessage,
createExtensionHost,
}: UseExtensionHostOptions): UseExtensionHostReturn {
const { exit } = useApp()
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
const hostRef = useRef<ExtensionHostInterface | null>(null)
const isReadyRef = useRef(false)
const cleanup = useCallback(async () => {
if (hostRef.current) {
await hostRef.current.dispose()
hostRef.current = null
isReadyRef.current = false
}
}, [])
useEffect(() => {
const init = async () => {
try {
const host = createExtensionHost({
mode,
user,
reasoningEffort,
provider,
apiKey,
model,
workspacePath,
extensionPath,
nonInteractive,
ephemeral,
debug,
exitOnComplete,
disableOutput: true,
})
hostRef.current = host
isReadyRef.current = true
host.on("extensionWebviewMessage", (msg) => onExtensionMessage(msg as ExtensionMessage))
host.client.on("taskCompleted", async () => {
setComplete(true)
setLoading(false)
if (exitOnComplete) {
await cleanup()
exit()
setTimeout(() => process.exit(0), 100)
}
})
host.client.on("error", (err: Error) => {
setError(err.message)
setLoading(false)
})
await host.activate()
// Request initial state from extension (triggers
// postStateToWebview which includes taskHistory).
host.sendToExtension({ type: "requestCommands" })
host.sendToExtension({ type: "requestModes" })
setLoading(false)
if (initialPrompt) {
setHasStartedTask(true)
setLoading(true)
addMessage({ id: randomUUID(), role: "user", content: initialPrompt })
await host.runTask(initialPrompt)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setLoading(false)
}
}
init()
return () => {
cleanup()
}
}, []) // Run once on mount
// Stable sendToExtension - uses ref to always access current host.
// This function reference never changes, preventing downstream
// useCallback/useMemo invalidations.
const sendToExtension = useCallback((msg: WebviewMessage) => {
hostRef.current?.sendToExtension(msg)
}, [])
// Stable runTask - uses ref to always access current host.
const runTask = useCallback((prompt: string): Promise<void> => {
if (!hostRef.current) {
return Promise.reject(new Error("Extension host not ready"))
}
return hostRef.current.runTask(prompt)
}, [])
// Memoized return object to prevent unnecessary re-renders in consumers.
return useMemo(
() => ({ isReady: isReadyRef.current, sendToExtension, runTask, cleanup }),
[sendToExtension, runTask, cleanup],
)
}

View file

@ -1,85 +0,0 @@
import { useEffect } from "react"
import { useUIStateStore } from "../stores/uiStateStore.js"
import type { PendingAsk } from "../types.js"
export interface UseFocusManagementOptions {
showApprovalPrompt: boolean
pendingAsk: PendingAsk | null
}
export interface UseFocusManagementReturn {
/** Whether focus can be toggled between scroll and input areas */
canToggleFocus: boolean
/** Whether scroll area should capture keyboard input */
isScrollAreaActive: boolean
/** Whether input area is active (for visual focus indicator) */
isInputAreaActive: boolean
/** Manual focus override */
manualFocus: "scroll" | "input" | null
/** Set manual focus override */
setManualFocus: (focus: "scroll" | "input" | null) => void
/** Toggle focus between scroll and input */
toggleFocus: () => void
}
/**
* Hook to manage focus state between scroll area and input area.
*
* Focus can be toggled when text input is available (not showing approval prompt).
* The hook automatically resets manual focus when the view changes.
*/
export function useFocusManagement({
showApprovalPrompt,
pendingAsk,
}: UseFocusManagementOptions): UseFocusManagementReturn {
const { showCustomInput, manualFocus, setManualFocus } = useUIStateStore()
// Determine if we're in a mode where focus can be toggled (text input is available)
const canToggleFocus =
!showApprovalPrompt &&
(!pendingAsk || // Initial input or task complete or loading
pendingAsk.type === "followup" || // Followup question with suggestions or custom input
showCustomInput) // Custom input mode
// Determine if scroll area should capture keyboard input
const isScrollAreaActive: boolean =
manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt)
// Determine if input area is active (for visual focus indicator)
const isInputAreaActive: boolean =
manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt
// Reset manual focus when view changes (e.g., agent starts responding)
useEffect(() => {
if (!canToggleFocus) {
setManualFocus(null)
}
}, [canToggleFocus, setManualFocus])
/**
* Toggle focus between scroll and input areas
*/
const toggleFocus = () => {
if (!canToggleFocus) {
return
}
const prev = manualFocus
if (prev === "scroll") {
setManualFocus("input")
} else if (prev === "input") {
setManualFocus("scroll")
} else {
setManualFocus(isScrollAreaActive ? "input" : "scroll")
}
}
return {
canToggleFocus,
isScrollAreaActive,
isInputAreaActive,
manualFocus,
setManualFocus,
toggleFocus,
}
}

View file

@ -1,112 +0,0 @@
import { useEffect, useRef } from "react"
import { FOLLOWUP_TIMEOUT_SECONDS } from "../../types/constants.js"
import { useUIStateStore } from "../stores/uiStateStore.js"
import type { PendingAsk } from "../types.js"
export interface UseFollowupCountdownOptions {
pendingAsk: PendingAsk | null
onAutoSubmit: (text: string) => void
}
/**
* Hook to manage auto-accept countdown timer for followup questions with suggestions.
*
* When a followup question appears with suggestions (and not in custom input mode),
* starts a countdown timer that auto-submits the first suggestion when it reaches zero.
*
* The countdown can be canceled by:
* - User navigating with arrow keys
* - User switching to custom input mode
* - Followup question changing/disappearing
*/
export function useFollowupCountdown({ pendingAsk, onAutoSubmit }: UseFollowupCountdownOptions) {
const { showCustomInput, countdownSeconds, setCountdownSeconds } = useUIStateStore()
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Use ref for onAutoSubmit to avoid stale closure issues without needing it in dependencies
const onAutoSubmitRef = useRef(onAutoSubmit)
useEffect(() => {
onAutoSubmitRef.current = onAutoSubmit
}, [onAutoSubmit])
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current)
}
}
}, [])
// Start countdown when a followup question with suggestions appears
useEffect(() => {
// Clear any existing countdown
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current)
countdownIntervalRef.current = null
}
// Only start countdown for followup questions with suggestions (not custom input mode)
if (
pendingAsk?.type === "followup" &&
pendingAsk.suggestions &&
pendingAsk.suggestions.length > 0 &&
!showCustomInput
) {
// Start countdown
setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS)
countdownIntervalRef.current = setInterval(() => {
const currentSeconds = useUIStateStore.getState().countdownSeconds
if (currentSeconds === null || currentSeconds <= 1) {
// Time's up! Auto-select first option
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current)
countdownIntervalRef.current = null
}
setCountdownSeconds(null)
// Auto-submit the first suggestion
if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) {
const firstSuggestion = pendingAsk.suggestions[0]
if (firstSuggestion) {
onAutoSubmitRef.current(firstSuggestion.answer)
}
}
} else {
setCountdownSeconds(currentSeconds - 1)
}
}, 1000)
} else {
// Only set to null if not already null to prevent unnecessary state updates
// This is critical to avoid infinite render loops
if (countdownSeconds !== null) {
setCountdownSeconds(null)
}
}
return () => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current)
countdownIntervalRef.current = null
}
}
// Note: countdownSeconds is intentionally NOT in deps - we only read it to avoid
// unnecessary state updates, not to react to its changes
}, [pendingAsk?.id, pendingAsk?.type, showCustomInput, setCountdownSeconds])
/**
* Cancel the countdown timer (called when user interacts with the menu)
*/
const cancelCountdown = () => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current)
countdownIntervalRef.current = null
}
setCountdownSeconds(null)
}
return {
countdownSeconds,
cancelCountdown,
}
}

View file

@ -1,170 +0,0 @@
import { useEffect, useRef } from "react"
import { useInput } from "ink"
import type { WebviewMessage } from "@roo-code/types"
import { matchesGlobalSequence } from "@/lib/utils/input.js"
import type { ModeResult } from "../components/autocomplete/index.js"
import { useUIStateStore } from "../stores/uiStateStore.js"
import { useCLIStore } from "../store.js"
export interface UseGlobalInputOptions {
canToggleFocus: boolean
isScrollAreaActive: boolean
pickerIsOpen: boolean
availableModes: ModeResult[]
currentMode: string | null
mode: string
sendToExtension: ((msg: WebviewMessage) => void) | null
showInfo: (msg: string, duration?: number) => void
exit: () => void
cleanup: () => Promise<void>
toggleFocus: () => void
closePicker: () => void
}
/**
* Hook to handle global keyboard shortcuts.
*
* Shortcuts:
* - Ctrl+C: Double-press to exit
* - Tab: Toggle focus between scroll area and input
* - Ctrl+M: Cycle through available modes
* - Ctrl+T: Toggle TODO list viewer
* - Escape: Cancel task (when loading) or close TODO viewer
*/
export function useGlobalInput({
canToggleFocus,
isScrollAreaActive: _isScrollAreaActive,
pickerIsOpen,
availableModes,
currentMode,
mode,
sendToExtension,
showInfo,
exit,
cleanup,
toggleFocus,
closePicker,
}: UseGlobalInputOptions): void {
const { isLoading, currentTodos } = useCLIStore()
const {
showTodoViewer,
setShowTodoViewer,
showExitHint: _showExitHint,
setShowExitHint,
pendingExit,
setPendingExit,
} = useUIStateStore()
// Track Ctrl+C presses for "press again to exit" behavior
const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (exitHintTimeout.current) {
clearTimeout(exitHintTimeout.current)
}
}
}, [])
// Handle global keyboard shortcuts
useInput((input, key) => {
// Tab to toggle focus between scroll area and input (only when input is available)
if (key.tab && canToggleFocus && !pickerIsOpen) {
toggleFocus()
return
}
// Ctrl+M to cycle through modes (only when not loading and we have available modes)
// Uses centralized global input sequence detection
if (matchesGlobalSequence(input, key, "ctrl-m")) {
// Don't allow mode switching while a task is in progress (loading)
if (isLoading) {
showInfo("Cannot switch modes while task is in progress", 2000)
return
}
// Need at least 2 modes to cycle
if (availableModes.length < 2) {
return
}
// Find current mode index
const currentModeSlug = currentMode || mode
const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug)
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length
const nextMode = availableModes[nextIndex]
if (nextMode && sendToExtension) {
sendToExtension({ type: "mode", text: nextMode.slug })
showInfo(`Switched to ${nextMode.name}`, 2000)
}
return
}
// Ctrl+T to toggle TODO list viewer
if (matchesGlobalSequence(input, key, "ctrl-t")) {
// Close picker if open
if (pickerIsOpen) {
closePicker()
}
// Toggle TODO viewer
setShowTodoViewer(!showTodoViewer)
if (!showTodoViewer && currentTodos.length === 0) {
showInfo("No TODO list available", 2000)
setShowTodoViewer(false)
}
return
}
// Escape key to close TODO viewer
if (key.escape && showTodoViewer) {
setShowTodoViewer(false)
return
}
// Escape key to cancel/pause task when loading (streaming)
if (key.escape && isLoading && sendToExtension) {
// If picker is open, let the picker handle escape first
if (pickerIsOpen) {
return
}
// Send cancel message to extension (same as webview-ui Cancel button)
sendToExtension({ type: "cancelTask" })
return
}
// Ctrl+C to exit
if (key.ctrl && input === "c") {
// If picker is open, close it first
if (pickerIsOpen) {
closePicker()
return
}
if (pendingExit) {
// Second press - exit immediately
if (exitHintTimeout.current) {
clearTimeout(exitHintTimeout.current)
}
cleanup().finally(() => {
exit()
process.exit(0)
})
} else {
// First press - show hint and wait for second press
setPendingExit(true)
setShowExitHint(true)
exitHintTimeout.current = setTimeout(() => {
setPendingExit(false)
setShowExitHint(false)
exitHintTimeout.current = null
}, 2000)
}
}
})
}

View file

@ -1,127 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { loadHistory, addToHistory } from "../../lib/storage/history.js"
export interface UseInputHistoryOptions {
isActive?: boolean
getCurrentInput?: () => string
}
export interface UseInputHistoryReturn {
addEntry: (entry: string) => Promise<void>
historyValue: string | null
isBrowsing: boolean
resetBrowsing: (currentInput?: string) => void
history: string[]
draft: string
setDraft: (value: string) => void
navigateUp: () => void
navigateDown: () => void
}
export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputHistoryReturn {
const { isActive = true, getCurrentInput } = options
// All history entries (oldest first, newest at end)
const [history, setHistory] = useState<string[]>([])
// Current position in history (-1 = not browsing, 0 = oldest, history.length-1 = newest)
const [historyIndex, setHistoryIndex] = useState(-1)
// The user's typed text before they started navigating history
const [draft, setDraft] = useState("")
// Flag to track if history has been loaded
const historyLoaded = useRef(false)
// Load history on mount
useEffect(() => {
if (!historyLoaded.current) {
historyLoaded.current = true
loadHistory()
.then(setHistory)
.catch(() => {
// Ignore load errors - history is not critical
})
}
}, [])
// Navigate to older history entry
const navigateUp = useCallback(() => {
if (!isActive) return
if (history.length === 0) return
if (historyIndex === -1) {
// Starting to browse - save current input as draft
if (getCurrentInput) {
setDraft(getCurrentInput())
}
// Go to newest entry
setHistoryIndex(history.length - 1)
} else if (historyIndex > 0) {
// Go to older entry
setHistoryIndex(historyIndex - 1)
}
// At oldest entry - stay there
}, [isActive, history, historyIndex, getCurrentInput])
// Navigate to newer history entry
const navigateDown = useCallback(() => {
if (!isActive) return
if (historyIndex === -1) return // Not browsing
if (historyIndex < history.length - 1) {
// Go to newer entry
setHistoryIndex(historyIndex + 1)
} else {
// At newest entry - return to draft
setHistoryIndex(-1)
}
}, [isActive, historyIndex, history.length])
// Add new entry to history
const addEntry = useCallback(async (entry: string) => {
const trimmed = entry.trim()
if (!trimmed) return
try {
const updated = await addToHistory(trimmed)
setHistory(updated)
} catch {
// Ignore save errors - history is not critical
}
// Reset navigation state
setHistoryIndex(-1)
setDraft("")
}, [])
// Reset browsing state
const resetBrowsing = useCallback((currentInput?: string) => {
setHistoryIndex(-1)
if (currentInput !== undefined) {
setDraft(currentInput)
}
}, [])
// Calculate the current history value to display
// When browsing, show history entry; when returning from browsing, show draft
let historyValue: string | null = null
if (historyIndex >= 0 && historyIndex < history.length) {
historyValue = history[historyIndex] ?? null
}
const isBrowsing = historyIndex !== -1
return {
addEntry,
historyValue,
isBrowsing,
resetBrowsing,
history,
draft,
setDraft,
navigateUp,
navigateDown,
}
}

View file

@ -1,410 +0,0 @@
import { useCallback, useRef } from "react"
import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli"
import type { TUIMessage, ToolData } from "../types.js"
import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
import { useCLIStore } from "../store.js"
import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js"
export interface UseMessageHandlersOptions {
nonInteractive: boolean
}
export interface UseMessageHandlersReturn {
handleExtensionMessage: (msg: ExtensionMessage) => void
seenMessageIds: React.MutableRefObject<Set<string>>
pendingCommandRef: React.MutableRefObject<string | null>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
/**
* Hook to handle messages from the extension.
*
* Processes three types of messages:
* 1. "say" messages - Information from the agent (text, tool output, reasoning)
* 2. "ask" messages - Requests for user input (approvals, followup questions)
* 3. Extension state updates - Mode changes, task history, file search results
*
* Transforms ClineMessage format to TUIMessage format and updates the store.
*/
export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
const {
addMessage,
setPendingAsk,
setComplete,
setLoading,
setHasStartedTask,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTokenUsage,
setRouterModels,
setTaskHistory,
currentTodos,
setTodos,
} = useCLIStore()
// Track seen message timestamps to filter duplicates and the prompt echo
const seenMessageIds = useRef<Set<string>>(new Set())
const firstTextMessageSkipped = useRef(false)
// Track pending command for injecting into command_output toolData
const pendingCommandRef = useRef<string | null>(null)
/**
* Map extension "say" messages to TUI messages
*/
const handleSayMessage = useCallback(
(ts: number, say: ClineSay, text: string, partial: boolean) => {
const messageId = ts.toString()
const isResuming = useCLIStore.getState().isResumingTask
if (say === "checkpoint_saved") {
return
}
if (say === "api_req_started") {
return
}
if (say === "user_feedback") {
seenMessageIds.current.add(messageId)
return
}
// Skip first text message ONLY for new tasks, not resumed tasks
// When resuming, we want to show all historical messages including the first one
if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
firstTextMessageSkipped.current = true
seenMessageIds.current.add(messageId)
return
}
if (seenMessageIds.current.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.current
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
pendingCommandRef.current = null
} else if (say === "reasoning") {
role = "thinking"
}
seenMessageIds.current.add(messageId)
addMessage({
id: messageId,
role,
content: text || "",
toolName,
toolDisplayName,
toolDisplayOutput,
partial,
originalType: say,
toolData,
})
},
[addMessage],
)
/**
* Handle extension "ask" messages
*/
const handleAskMessage = useCallback(
(ts: number, ask: ClineAsk, text: string, partial: boolean) => {
const messageId = ts.toString()
if (partial) {
return
}
if (seenMessageIds.current.has(messageId)) {
return
}
if (ask === "command_output") {
seenMessageIds.current.add(messageId)
return
}
// Handle resume_task and resume_completed_task - stop loading and show text input
// Do not set pendingAsk - just stop loading so user sees normal input to type new message
if (ask === "resume_task" || ask === "resume_completed_task") {
seenMessageIds.current.add(messageId)
setLoading(false)
// Mark that a task has been started so subsequent messages continue the task
// (instead of starting a brand new task via runTask)
setHasStartedTask(true)
// Clear the resuming flag since we're now ready for interaction
// Historical messages should already be displayed from state processing
useCLIStore.getState().setIsResumingTask(false)
// Do not set pendingAsk - let the normal text input appear
return
}
if (ask === "completion_result") {
seenMessageIds.current.add(messageId)
setComplete(true)
setLoading(false)
// Parse the completion result and add a message for CompletionTool to render
try {
const completionInfo = JSON.parse(text) as Record<string, unknown>
const toolData: 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,
})
} catch {
// If parsing fails, still add a basic completion message
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
}
// Track pending command BEFORE nonInteractive handling
// This ensures we capture the command text for later injection into command_output toolData
if (ask === "command") {
pendingCommandRef.current = text
}
if (nonInteractive && ask !== "followup") {
seenMessageIds.current.add(messageId)
if (ask === "tool") {
let toolName: string | undefined
let toolDisplayName: string | undefined
let toolDisplayOutput: string | undefined
let formattedContent = text || ""
let toolData: ToolData | undefined
let todos: TodoItem[] | undefined
let previousTodos: TodoItem[] | undefined
try {
const toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = toolInfo.tool as string
toolDisplayName = toolInfo.tool as string
toolDisplayOutput = formatToolOutput(toolInfo)
formattedContent = formatToolAskMessage(toolInfo)
// Extract structured toolData for rich rendering
toolData = extractToolData(toolInfo)
// Special handling for update_todo_list tool - extract todos
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
const parsedTodos = parseTodosFromToolInfo(toolInfo)
if (parsedTodos && parsedTodos.length > 0) {
todos = parsedTodos
// Capture previous todos before updating global state
previousTodos = [...currentTodos]
setTodos(parsedTodos)
}
}
} catch {
// Use raw text if not valid JSON
}
addMessage({
id: messageId,
role: "tool",
content: formattedContent,
toolName,
toolDisplayName,
toolDisplayOutput,
originalType: ask,
toolData,
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 if not valid JSON
}
}
// Note: ask === "command" is handled above before the nonInteractive block
seenMessageIds.current.add(messageId)
setPendingAsk({
id: messageId,
type: ask,
content: questionText,
suggestions,
})
},
[addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos],
)
/**
* Handle all extension messages
*/
const handleExtensionMessage = useCallback(
(msg: ExtensionMessage) => {
if (msg.type === "state") {
const state = msg.state
if (!state) {
return
}
// Extract and update current mode from state
const newMode = state.mode
if (newMode) {
setCurrentMode(newMode)
}
// Extract and update task history from state
const newTaskHistory = state.taskHistory
if (newTaskHistory && Array.isArray(newTaskHistory)) {
setTaskHistory(newTaskHistory)
}
const clineMessages = state.clineMessages
if (clineMessages) {
for (const clineMsg of clineMessages) {
const ts = clineMsg.ts
const type = clineMsg.type
const say = clineMsg.say
const ask = clineMsg.ask
const text = clineMsg.text || ""
const partial = clineMsg.partial || false
if (type === "say" && say) {
handleSayMessage(ts, say, text, partial)
} else if (type === "ask" && ask) {
handleAskMessage(ts, ask, text, partial)
}
}
// Compute token usage metrics from clineMessages
// Skip first message (task prompt) as per webview UI pattern
if (clineMessages.length > 1) {
const processed = consolidateApiRequests(
consolidateCommands(clineMessages.slice(1) as ClineMessage[]),
)
const metrics = consolidateTokenUsage(processed)
setTokenUsage(metrics)
}
}
// After processing state, clear the resuming flag if it was set
// This ensures the flag is cleared even if no resume_task ask message is received
if (useCLIStore.getState().isResumingTask) {
useCLIStore.getState().setIsResumingTask(false)
}
} else if (msg.type === "messageUpdated") {
const clineMessage = msg.clineMessage
if (!clineMessage) {
return
}
const ts = clineMessage.ts
const type = clineMessage.type
const say = clineMessage.say
const ask = clineMessage.ask
const text = clineMessage.text || ""
const partial = clineMessage.partial || false
if (type === "say" && say) {
handleSayMessage(ts, say, text, partial)
} else if (type === "ask" && ask) {
handleAskMessage(ts, ask, text, partial)
}
} else if (msg.type === "fileSearchResults") {
setFileSearchResults((msg.results as FileResult[]) || [])
} else if (msg.type === "commands") {
setAllSlashCommands((msg.commands as SlashCommandResult[]) || [])
} else if (msg.type === "modes") {
setAvailableModes((msg.modes as ModeResult[]) || [])
} else if (msg.type === "routerModels") {
if (msg.routerModels) {
setRouterModels(msg.routerModels)
}
}
},
[
handleSayMessage,
handleAskMessage,
setFileSearchResults,
setAllSlashCommands,
setAvailableModes,
setCurrentMode,
setTokenUsage,
setRouterModels,
setTaskHistory,
],
)
return {
handleExtensionMessage,
seenMessageIds,
pendingCommandRef,
firstTextMessageSkipped,
}
}

View file

@ -1,168 +0,0 @@
import { useCallback } from "react"
import type { WebviewMessage } from "@roo-code/types"
import type {
AutocompletePickerState,
AutocompleteInputHandle,
ModeResult,
HistoryResult,
} from "../components/autocomplete/index.js"
import { useCLIStore } from "../store.js"
import { useUIStateStore } from "../stores/uiStateStore.js"
export interface UsePickerHandlersOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
autocompleteRef: React.RefObject<AutocompleteInputHandle<any>>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
followupAutocompleteRef: React.RefObject<AutocompleteInputHandle<any>>
sendToExtension: ((msg: WebviewMessage) => void) | null
showInfo: (msg: string, duration?: number) => void
seenMessageIds: React.MutableRefObject<Set<string>>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
export interface UsePickerHandlersReturn {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handlePickerStateChange: (state: AutocompletePickerState<any>) => void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handlePickerSelect: (item: any) => void
handlePickerClose: () => void
handlePickerIndexChange: (index: number) => void
}
/**
* Hook to handle autocomplete picker interactions.
*
* Responsibilities:
* - Handle picker state changes from AutocompleteInput
* - Handle item selection (special handling for modes and history items)
* - Handle mode switching via picker
* - Handle task switching via history picker
* - Handle picker close and index change
*/
export function usePickerHandlers({
autocompleteRef,
followupAutocompleteRef,
sendToExtension,
showInfo,
seenMessageIds,
firstTextMessageSkipped,
}: UsePickerHandlersOptions): UsePickerHandlersReturn {
const { isLoading, currentTaskId, setCurrentTaskId } = useCLIStore()
const { pickerState, setPickerState } = useUIStateStore()
/**
* Handle picker state changes from AutocompleteInput
*/
const handlePickerStateChange = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(state: AutocompletePickerState<any>) => {
setPickerState(state)
},
[setPickerState],
)
/**
* Handle item selection from external PickerSelect
*/
const handlePickerSelect = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(item: any) => {
// Check if this is a mode selection.
if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) {
const modeItem = item as ModeResult
if (sendToExtension) {
sendToExtension({ type: "mode", text: modeItem.slug })
}
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
}
// Check if this is a history item selection.
else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) {
const historyItem = item as HistoryResult
// Don't allow task switching while a task is in progress (loading).
if (isLoading) {
showInfo("Cannot switch tasks while task is in progress", 2000)
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
return
}
// If selecting the same task that's already loaded, just close the picker.
if (historyItem.id === currentTaskId) {
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
return
}
// Send showTaskWithId message to extension to resume the task
if (sendToExtension) {
// Use selective reset that preserves global state (taskHistory, modes, commands)
useCLIStore.getState().resetForTaskSwitch()
// Set the resuming flag so message handlers know we're resuming
// This prevents skipping the first text message (which is historical)
useCLIStore.getState().setIsResumingTask(true)
// Track which task we're switching to
setCurrentTaskId(historyItem.id)
// Reset refs to avoid stale state across task switches
seenMessageIds.current.clear()
firstTextMessageSkipped.current = false
// Send message to resume the selected task
// This triggers createTaskWithHistoryItem -> postStateToWebview
// which includes clineMessages and handles mode restoration
sendToExtension({ type: "showTaskWithId", text: historyItem.id })
}
// Close the picker
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
} else {
// Handle other item selections normally
autocompleteRef.current?.handleItemSelect(item)
followupAutocompleteRef.current?.handleItemSelect(item)
}
},
[
pickerState.activeTrigger,
isLoading,
showInfo,
currentTaskId,
setCurrentTaskId,
sendToExtension,
autocompleteRef,
followupAutocompleteRef,
seenMessageIds,
firstTextMessageSkipped,
],
)
/**
* Handle picker close from external PickerSelect
*/
const handlePickerClose = useCallback(() => {
autocompleteRef.current?.closePicker()
followupAutocompleteRef.current?.closePicker()
}, [autocompleteRef, followupAutocompleteRef])
/**
* Handle picker index change from external PickerSelect
*/
const handlePickerIndexChange = useCallback(
(index: number) => {
autocompleteRef.current?.handleIndexChange(index)
followupAutocompleteRef.current?.handleIndexChange(index)
},
[autocompleteRef, followupAutocompleteRef],
)
return {
handlePickerStateChange,
handlePickerSelect,
handlePickerClose,
handlePickerIndexChange,
}
}

View file

@ -1,183 +0,0 @@
import { useCallback } from "react"
import { randomUUID } from "crypto"
import type { WebviewMessage } from "@roo-code/types"
import { getGlobalCommand } from "../../lib/utils/commands.js"
import { useCLIStore } from "../store.js"
import { useUIStateStore } from "../stores/uiStateStore.js"
export interface UseTaskSubmitOptions {
sendToExtension: ((msg: WebviewMessage) => void) | null
runTask: ((prompt: string) => Promise<void>) | null
seenMessageIds: React.MutableRefObject<Set<string>>
firstTextMessageSkipped: React.MutableRefObject<boolean>
}
export interface UseTaskSubmitReturn {
handleSubmit: (text: string) => Promise<void>
handleApprove: () => void
handleReject: () => void
}
/**
* Hook to handle task submission, user responses, and approvals.
*
* Responsibilities:
* - Process user message submissions
* - Detect and handle global commands (like /new)
* - Handle pending ask responses
* - Start new tasks or continue existing ones
* - Handle Y/N approval responses
*/
export function useTaskSubmit({
sendToExtension,
runTask,
seenMessageIds,
firstTextMessageSkipped,
}: UseTaskSubmitOptions): UseTaskSubmitReturn {
const {
pendingAsk,
hasStartedTask,
isComplete,
addMessage,
setPendingAsk,
setHasStartedTask,
setLoading,
setComplete,
setError,
} = useCLIStore()
const { setShowCustomInput, setIsTransitioningToCustomInput } = useUIStateStore()
/**
* Handle user text submission (from input or followup question)
*/
const handleSubmit = useCallback(
async (text: string) => {
if (!sendToExtension || !text.trim()) {
return
}
const trimmedText = text.trim()
if (trimmedText === "__CUSTOM__") {
return
}
// Check for CLI global action commands (e.g., /new)
if (trimmedText.startsWith("/")) {
const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/)
if (commandMatch && commandMatch[1]) {
const globalCommand = getGlobalCommand(commandMatch[1])
if (globalCommand?.action === "clearTask") {
// Reset CLI state and send clearTask to extension.
useCLIStore.getState().reset()
// Reset component-level refs to avoid stale message tracking.
seenMessageIds.current.clear()
firstTextMessageSkipped.current = false
sendToExtension({ type: "clearTask" })
// Re-request state, commands and modes since reset() cleared them.
sendToExtension({ type: "requestCommands" })
sendToExtension({ type: "requestModes" })
return
}
}
}
if (pendingAsk) {
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
setPendingAsk(null)
setShowCustomInput(false)
setIsTransitioningToCustomInput(false)
setLoading(true)
} else if (!hasStartedTask) {
setHasStartedTask(true)
setLoading(true)
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
try {
if (runTask) {
await runTask(trimmedText)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setLoading(false)
}
} else {
if (isComplete) {
setComplete(false)
}
setLoading(true)
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: trimmedText,
})
}
},
[
sendToExtension,
runTask,
pendingAsk,
hasStartedTask,
isComplete,
addMessage,
setPendingAsk,
setHasStartedTask,
setLoading,
setComplete,
setError,
setShowCustomInput,
setIsTransitioningToCustomInput,
seenMessageIds,
firstTextMessageSkipped,
],
)
/**
* Handle approval (Y key)
*/
const handleApprove = useCallback(() => {
if (!sendToExtension) {
return
}
sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" })
setPendingAsk(null)
setLoading(true)
}, [sendToExtension, setPendingAsk, setLoading])
/**
* Handle rejection (N key)
*/
const handleReject = useCallback(() => {
if (!sendToExtension) {
return
}
sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" })
setPendingAsk(null)
setLoading(true)
}, [sendToExtension, setPendingAsk, setLoading])
return {
handleSubmit,
handleApprove,
handleReject,
}
}

View file

@ -1,59 +0,0 @@
/**
* useTerminalSize - Hook that tracks terminal dimensions and re-renders on resize
* Includes debouncing to prevent rendering issues during rapid resizing
*/
import { useState, useEffect, useRef } from "react"
interface TerminalSize {
columns: number
rows: number
}
/**
* Returns the current terminal size and re-renders when it changes
* Debounces resize events to prevent rendering artifacts
*/
export function useTerminalSize(): TerminalSize {
// Get initial size synchronously - this is the value used for first render
const [size, setSize] = useState<TerminalSize>(() => ({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
}))
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const handleResize = () => {
// Clear any pending debounce
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
// Debounce resize events by 50ms
debounceTimer.current = setTimeout(() => {
// Clear the terminal before updating size to prevent artifacts
process.stdout.write("\x1b[2J\x1b[H")
setSize({
columns: process.stdout.columns || 80,
rows: process.stdout.rows || 24,
})
debounceTimer.current = null
}, 50)
}
// Listen for resize events
process.stdout.on("resize", handleResize)
// Cleanup
return () => {
process.stdout.off("resize", handleResize)
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
return size
}

View file

@ -1,196 +0,0 @@
import { create } from "zustand"
import { useEffect, useCallback, useRef } from "react"
/**
* Toast message types for different visual styles
*/
export type ToastType = "info" | "success" | "warning" | "error"
/**
* A single toast message in the queue
*/
export interface Toast {
id: string
message: string
type: ToastType
/** Duration in milliseconds before auto-dismiss (default: 3000) */
duration: number
/** Timestamp when the toast was created */
createdAt: number
}
/**
* Toast queue store state
*/
interface ToastState {
/** Queue of active toasts (FIFO - first one is displayed) */
toasts: Toast[]
/** Add a toast to the queue */
addToast: (message: string, type?: ToastType, duration?: number) => string
/** Remove a specific toast by ID */
removeToast: (id: string) => void
/** Clear all toasts */
clearToasts: () => void
}
/**
* Default toast duration in milliseconds
*/
const DEFAULT_DURATION = 3000
/**
* Generate a unique ID for toasts
*/
let toastIdCounter = 0
function generateToastId(): string {
return `toast-${Date.now()}-${++toastIdCounter}`
}
/**
* Zustand store for toast queue management
*/
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message: string, type: ToastType = "info", duration: number = DEFAULT_DURATION) => {
const id = generateToastId()
const toast: Toast = {
id,
message,
type,
duration,
createdAt: Date.now(),
}
// Replace any existing toasts - new toast shows immediately
// This provides better UX as users see the most recent message right away
set(() => ({
toasts: [toast],
}))
return id
},
removeToast: (id: string) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
},
clearToasts: () => {
set({ toasts: [] })
},
}))
/**
* Hook for displaying and managing toasts with auto-expiry.
* Returns the current toast (if any) and utility functions.
*
* The hook handles auto-dismissal of toasts after their duration expires.
*/
export function useToast() {
const { toasts, addToast, removeToast, clearToasts } = useToastStore()
// Track active timers for cleanup
const timersRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
// Get the current toast to display (first in queue)
const currentToast = toasts.length > 0 ? toasts[0] : null
// Set up auto-dismissal timer for current toast
useEffect(() => {
if (!currentToast) {
return
}
// Check if timer already exists for this toast
if (timersRef.current.has(currentToast.id)) {
return
}
// Calculate remaining time (accounts for time already elapsed)
const elapsed = Date.now() - currentToast.createdAt
const remainingTime = Math.max(0, currentToast.duration - elapsed)
const timer = setTimeout(() => {
removeToast(currentToast.id)
timersRef.current.delete(currentToast.id)
}, remainingTime)
timersRef.current.set(currentToast.id, timer)
return () => {
// Clean up timer if toast is removed before expiry
const existingTimer = timersRef.current.get(currentToast.id)
if (existingTimer) {
clearTimeout(existingTimer)
timersRef.current.delete(currentToast.id)
}
}
}, [currentToast?.id, currentToast?.createdAt, currentToast?.duration, removeToast])
// Cleanup all timers on unmount
useEffect(() => {
return () => {
timersRef.current.forEach((timer) => clearTimeout(timer))
timersRef.current.clear()
}
}, [])
// Convenience methods for different toast types
const showToast = useCallback(
(message: string, type?: ToastType, duration?: number) => {
return addToast(message, type, duration)
},
[addToast],
)
const showInfo = useCallback(
(message: string, duration?: number) => {
return addToast(message, "info", duration)
},
[addToast],
)
const showSuccess = useCallback(
(message: string, duration?: number) => {
return addToast(message, "success", duration)
},
[addToast],
)
const showWarning = useCallback(
(message: string, duration?: number) => {
return addToast(message, "warning", duration)
},
[addToast],
)
const showError = useCallback(
(message: string, duration?: number) => {
return addToast(message, "error", duration)
},
[addToast],
)
return {
/** Current toast being displayed (first in queue) */
currentToast,
/** All toasts in the queue */
toasts,
/** Generic toast display method */
showToast,
/** Show an info toast */
showInfo,
/** Show a success toast */
showSuccess,
/** Show a warning toast */
showWarning,
/** Show an error toast */
showError,
/** Remove a specific toast by ID */
removeToast,
/** Clear all toasts */
clearToasts,
}
}

View file

@ -1,295 +0,0 @@
import { create } from "zustand"
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js"
import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js"
/**
* Shallow array equality check - compares array length and element references.
* Used to prevent unnecessary state updates when array content hasn't changed.
*/
function shallowArrayEqual<T>(a: T[], b: T[]): boolean {
if (a === b) return true
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
/**
* Streaming message debounce configuration.
* Batches rapid partial message updates to reduce re-renders during streaming.
* Higher values = fewer renders but text appears more "chunky"
* Lower values = smoother text but more renders
*/
const STREAMING_DEBOUNCE_MS = 150 // 150ms debounce for aggressive batching
// Pending streaming updates - batched and flushed after debounce interval
interface PendingStreamUpdate {
id: string
content: string
partial: boolean
timestamp: number
}
const pendingStreamUpdates: Map<string, PendingStreamUpdate> = new Map()
let streamingDebounceTimer: ReturnType<typeof setTimeout> | null = null
/**
* RouterModels type for context window lookup.
* Simplified version - we only need contextWindow from ModelInfo.
*/
export type RouterModels = Record<string, Record<string, { contextWindow?: number }>>
/**
* CLI application state.
*
* Note: Autocomplete picker UI state (isOpen, selectedIndex) is now managed
* by the useAutocompletePicker hook. The store only holds data that needs
* to be shared between components or persisted (like search results from API).
*/
interface CLIState {
// Message history
messages: TUIMessage[]
pendingAsk: PendingAsk | null
// Task state
isLoading: boolean
isComplete: boolean
hasStartedTask: boolean
error: string | null
// Task resumption flag - true when resuming a task from history
// Used to modify message processing behavior (e.g., don't skip first text message)
isResumingTask: boolean
// Autocomplete data (from API/extension)
fileSearchResults: FileResult[]
allSlashCommands: SlashCommandResult[]
availableModes: ModeResult[]
// Task history (for resuming previous tasks)
taskHistory: TaskHistoryItem[]
// Current task ID (for detecting same-task reselection)
currentTaskId: string | null
// Current mode (updated reactively when mode changes)
currentMode: string | null
// Token usage metrics (from getApiMetrics)
tokenUsage: TokenUsage | null
// Model info for context window lookup
routerModels: RouterModels | null
apiConfiguration: ProviderSettings | null
// Todo list tracking
currentTodos: TodoItem[]
previousTodos: TodoItem[]
}
interface CLIActions {
// Message actions
addMessage: (msg: TUIMessage) => void
updateMessage: (id: string, content: string, partial?: boolean) => void
// Task actions
setPendingAsk: (ask: PendingAsk | null) => void
setLoading: (loading: boolean) => void
setComplete: (complete: boolean) => void
setHasStartedTask: (started: boolean) => void
setError: (error: string | null) => void
reset: () => void
/** Reset for task switching - preserves global state (taskHistory, modes, commands) */
resetForTaskSwitch: () => void
/** Set the isResumingTask flag - used when resuming a task from history */
setIsResumingTask: (isResuming: boolean) => void
// Autocomplete data actions
setFileSearchResults: (results: FileResult[]) => void
setAllSlashCommands: (commands: SlashCommandResult[]) => void
setAvailableModes: (modes: ModeResult[]) => void
// Task history action
setTaskHistory: (history: TaskHistoryItem[]) => void
// Current task ID action
setCurrentTaskId: (taskId: string | null) => void
// Current mode action
setCurrentMode: (mode: string | null) => void
// Metrics actions
setTokenUsage: (usage: TokenUsage | null) => void
setRouterModels: (models: RouterModels | null) => void
setApiConfiguration: (config: ProviderSettings | null) => void
// Todo actions
setTodos: (todos: TodoItem[]) => void
}
const initialState: CLIState = {
messages: [],
pendingAsk: null,
isLoading: false,
isComplete: false,
hasStartedTask: false,
error: null,
isResumingTask: false,
fileSearchResults: [],
allSlashCommands: [],
availableModes: [],
taskHistory: [],
currentTaskId: null,
currentMode: null,
tokenUsage: null,
routerModels: null,
apiConfiguration: null,
currentTodos: [],
previousTodos: [],
}
export const useCLIStore = create<CLIState & CLIActions>((set, get) => ({
...initialState,
addMessage: (msg) => {
const state = get()
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
// For NEW messages (not updates) - always apply immediately
if (existingIndex === -1) {
set({ messages: [...state.messages, msg] })
return
}
// For UPDATES to existing messages:
// If partial (streaming) and message exists, debounce the update
if (msg.partial) {
// Queue the update
pendingStreamUpdates.set(msg.id, {
id: msg.id,
content: msg.content,
partial: true,
timestamp: Date.now(),
})
// Schedule flush if not already scheduled
if (!streamingDebounceTimer) {
streamingDebounceTimer = setTimeout(() => {
// Flush all pending updates as a single batch
const currentState = get()
const updates = Array.from(pendingStreamUpdates.values())
pendingStreamUpdates.clear()
streamingDebounceTimer = null
if (updates.length === 0) return
// Apply all pending updates in one state change
const newMessages = [...currentState.messages]
let hasChanges = false
for (const update of updates) {
const idx = newMessages.findIndex((m) => m.id === update.id)
if (idx !== -1 && newMessages[idx]) {
newMessages[idx] = {
...newMessages[idx],
content: update.content,
partial: update.partial,
}
hasChanges = true
}
}
if (hasChanges) {
set({ messages: newMessages })
}
}, STREAMING_DEBOUNCE_MS)
}
return
}
// Non-partial update (final message) - apply immediately and clear any pending
// This ensures the final complete message is always shown
pendingStreamUpdates.delete(msg.id)
const updated = [...state.messages]
updated[existingIndex] = msg
set({ messages: updated })
},
updateMessage: (id, content, partial) =>
set((state) => {
const index = state.messages.findIndex((m) => m.id === id)
if (index === -1) {
return state
}
const existing = state.messages[index]
if (!existing) {
return state
}
const updated = [...state.messages]
updated[index] = {
...existing,
content,
partial: partial !== undefined ? partial : existing.partial,
}
return { messages: updated }
}),
setPendingAsk: (ask) => set({ pendingAsk: ask }),
setLoading: (loading) => set({ isLoading: loading }),
setComplete: (complete) => set({ isComplete: complete }),
setHasStartedTask: (started) => set({ hasStartedTask: started }),
setError: (error) => set({ error }),
reset: () => set(initialState),
resetForTaskSwitch: () =>
set((state) => ({
// Clear task-specific state
messages: [],
pendingAsk: null,
isLoading: false,
isComplete: false,
hasStartedTask: false,
error: null,
isResumingTask: false,
tokenUsage: null,
currentTodos: [],
previousTodos: [],
// currentTaskId is preserved - will be updated to new task ID by caller
currentTaskId: state.currentTaskId,
// PRESERVE global state - don't clear these
taskHistory: state.taskHistory,
availableModes: state.availableModes,
allSlashCommands: state.allSlashCommands,
fileSearchResults: state.fileSearchResults,
currentMode: state.currentMode,
routerModels: state.routerModels,
apiConfiguration: state.apiConfiguration,
})),
setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }),
// Use shallow equality to prevent unnecessary re-renders when array content is the same
setFileSearchResults: (results) =>
set((state) => (shallowArrayEqual(state.fileSearchResults, results) ? state : { fileSearchResults: results })),
setAllSlashCommands: (commands) =>
set((state) => (shallowArrayEqual(state.allSlashCommands, commands) ? state : { allSlashCommands: commands })),
setAvailableModes: (modes) =>
set((state) => (shallowArrayEqual(state.availableModes, modes) ? state : { availableModes: modes })),
setTaskHistory: (history) =>
set((state) => (shallowArrayEqual(state.taskHistory, history) ? state : { taskHistory: history })),
setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }),
setCurrentMode: (mode) => set({ currentMode: mode }),
setTokenUsage: (usage) => set({ tokenUsage: usage }),
setRouterModels: (models) => set({ routerModels: models }),
setApiConfiguration: (config) => set({ apiConfiguration: config }),
setTodos: (todos) => set((state) => ({ previousTodos: state.currentTodos, currentTodos: todos })),
}))

View file

@ -1,87 +0,0 @@
import { create } from "zustand"
import type { AutocompletePickerState } from "../components/autocomplete/types.js"
/**
* UI-specific state that doesn't need to persist across task switches.
* This separates UI state from task/message state in the main CLI store.
*/
interface UIState {
// Exit handling state
showExitHint: boolean
pendingExit: boolean
// Countdown timer for auto-accepting followup questions
countdownSeconds: number | null
// Custom input mode for followup questions
showCustomInput: boolean
isTransitioningToCustomInput: boolean
// Focus management for scroll area vs input
manualFocus: "scroll" | "input" | null
// TODO viewer overlay
showTodoViewer: boolean
// Autocomplete picker state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pickerState: AutocompletePickerState<any>
}
interface UIActions {
// Exit handling actions
setShowExitHint: (show: boolean) => void
setPendingExit: (pending: boolean) => void
// Countdown timer actions
setCountdownSeconds: (seconds: number | null) => void
// Custom input mode actions
setShowCustomInput: (show: boolean) => void
setIsTransitioningToCustomInput: (transitioning: boolean) => void
// Focus management actions
setManualFocus: (focus: "scroll" | "input" | null) => void
// TODO viewer actions
setShowTodoViewer: (show: boolean) => void
// Picker state actions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPickerState: (state: AutocompletePickerState<any>) => void
// Reset all UI state to defaults
resetUIState: () => void
}
const initialState: UIState = {
showExitHint: false,
pendingExit: false,
countdownSeconds: null,
showCustomInput: false,
isTransitioningToCustomInput: false,
manualFocus: null,
showTodoViewer: false,
pickerState: {
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
},
}
export const useUIStateStore = create<UIState & UIActions>((set) => ({
...initialState,
setShowExitHint: (show) => set({ showExitHint: show }),
setPendingExit: (pending) => set({ pendingExit: pending }),
setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }),
setShowCustomInput: (show) => set({ showCustomInput: show }),
setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }),
setManualFocus: (focus) => set({ manualFocus: focus }),
setShowTodoViewer: (show) => set({ showTodoViewer: show }),
setPickerState: (state) => set({ pickerState: state }),
resetUIState: () => set(initialState),
}))

View file

@ -1,79 +0,0 @@
/**
* Theme configuration for Roo Code CLI TUI
* Using Hardcore color scheme
*/
// Hardcore palette
const hardcore = {
// Accent colors
pink: "#F92672",
pinkLight: "#FF669D",
green: "#A6E22E",
greenLight: "#BEED5F",
orange: "#FD971F",
yellow: "#E6DB74",
cyan: "#66D9EF",
purple: "#9E6FFE",
// Text colors
text: "#F8F8F2",
subtext1: "#CCCCC6",
subtext0: "#A3BABF",
// Overlay colors
overlay2: "#A3BABF",
overlay1: "#5E7175",
overlay0: "#505354",
// Surface colors
surface2: "#505354",
surface1: "#383a3e",
surface0: "#2d2e2e",
// Base colors
base: "#1B1D1E",
mantle: "#161819",
crust: "#101112",
}
// Title and branding colors
export const titleColor = hardcore.orange // Orange for title
export const welcomeText = hardcore.text // Standard text
export const asciiColor = hardcore.cyan // Cyan for ASCII art
// Tips section colors
export const tipsHeader = hardcore.orange // Orange for tips headers
export const tipsText = hardcore.subtext0 // Subtle text for tips
// Header text colors (for messages)
export const userHeader = hardcore.purple // Purple for user header
export const rooHeader = hardcore.yellow // Yellow for roo
export const toolHeader = hardcore.cyan // Cyan for tool headers
export const thinkingHeader = hardcore.overlay1 // Subtle gray for thinking header
// Message text colors
export const userText = hardcore.text // Standard text for user
export const rooText = hardcore.text // Standard text for roo
export const toolText = hardcore.subtext0 // Subtle text for tool output
export const thinkingText = hardcore.overlay2 // Subtle gray for thinking text
// UI element colors
export const borderColor = hardcore.surface1 // Surface color for borders
export const borderColorActive = hardcore.purple // Active/focused border color
export const dimText = hardcore.overlay1 // Dim text
export const promptColor = hardcore.overlay2 // Prompt indicator
export const promptColorActive = hardcore.cyan // Active prompt color
export const placeholderColor = hardcore.overlay0 // Placeholder text
// Status colors
export const successColor = hardcore.green // Green for success
export const errorColor = hardcore.pink // Pink for errors
export const warningColor = hardcore.yellow // Yellow for warnings
// Focus indicator colors
export const focusColor = hardcore.cyan // Focus indicator (cyan accent)
export const scrollActiveColor = hardcore.purple // Scroll area active indicator (purple)
export const scrollTrackColor = hardcore.surface1 // Muted scrollbar track color
// Base text color
export const text = hardcore.text // Standard text color

View file

@ -1,123 +0,0 @@
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
/** File path */
path?: string
/** Whether the file is outside the workspace */
isOutsideWorkspace?: boolean
/** Whether the file is write-protected */
isProtected?: boolean
/** Unified diff content */
diff?: string
/** Diff statistics */
diffStats?: { added: number; removed: number }
/** General content (file content, search results, etc.) */
content?: string
// Search operation fields
/** Search regex pattern */
regex?: string
/** File pattern filter */
filePattern?: string
/** Search query (for codebase search) */
query?: string
// Mode operation fields
/** Target mode slug */
mode?: string
/** Reason for mode switch or other actions */
reason?: string
// Command operation fields
/** Command string */
command?: string
/** Command output */
output?: string
// Browser operation fields
/** Browser action type */
action?: string
/** Browser URL */
url?: string
/** Click/hover coordinates */
coordinate?: string
// Batch operation fields
/** Batch file reads */
batchFiles?: Array<{
path: string
lineSnippet?: string
isOutsideWorkspace?: boolean
key?: string
content?: string
}>
/** Batch diff operations */
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 text for ask_followup_question */
question?: string
/** Result text for attempt_completion */
result?: string
// Additional display hints
/** Line number for context */
lineNumber?: number
/** Additional file count for batch operations */
additionalFileCount?: number
}
export interface TUIMessage {
id: string
role: MessageRole
content: string
toolName?: string
toolDisplayName?: string
toolDisplayOutput?: string
hasPendingToolCalls?: boolean
partial?: boolean
originalType?: ClineAsk | ClineSay
/** TODO items for update_todo_list tool messages */
todos?: TodoItem[]
/** Previous TODO items for diff display */
previousTodos?: TodoItem[]
/** Structured tool data for rich rendering */
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
}

View file

@ -1,2 +0,0 @@
export * from "./tools.js"
export * from "./views.js"

View file

@ -1,52 +0,0 @@
import type { TUIMessage, PendingAsk, View } from "../types.js"
/**
* Determine the current view state based on messages and pending asks
*/
export function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View {
// If there's a pending ask requiring text input, show input
if (pendingAsk?.type === "followup") {
return "UserInput"
}
// If there's any pending ask (approval), don't show thinking
if (pendingAsk) {
return "UserInput"
}
// Initial state or empty - awaiting user input
if (messages.length === 0) {
return "UserInput"
}
const lastMessage = messages.at(-1)
if (!lastMessage) {
return "UserInput"
}
// User just sent a message, waiting for response
if (lastMessage.role === "user") {
return "AgentResponse"
}
// Assistant replied
if (lastMessage.role === "assistant") {
if (lastMessage.hasPendingToolCalls) {
return "ToolUse"
}
// If loading, still waiting for more
if (isLoading) {
return "AgentResponse"
}
return "UserInput"
}
// Tool result received, waiting for next assistant response
if (lastMessage.role === "tool") {
return "AgentResponse"
}
return "Default"
}

View file

@ -3,8 +3,6 @@
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]

View file

@ -4,7 +4,7 @@ export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: false,
clean: true,
sourcemap: true,
target: "node20",
platform: "node",
@ -20,12 +20,5 @@ export default defineConfig({
"@anthropic-ai/vertex-sdk",
// Keep @vscode/ripgrep external - we bundle the binary separately
"@vscode/ripgrep",
// Optional dev dependency of ink - not needed at runtime
"react-devtools-core",
],
esbuildOptions(options) {
// Enable JSX for React/Ink components
options.jsx = "automatic"
options.jsxImportSource = "react"
},
})