mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
88092ec955
commit
524709de5f
92 changed files with 1324 additions and 11651 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
125
apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts
Normal file
125
apps/cli/src/ui-next/component/prompt/autocomplete-builders.ts
Normal 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)
|
||||
}
|
||||
105
apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts
Normal file
105
apps/cli/src/ui-next/component/prompt/autocomplete-handlers.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
51
apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts
Normal file
51
apps/cli/src/ui-next/component/prompt/autocomplete-memos.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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 ?? "› "}
|
||||
|
|
|
|||
13
apps/cli/src/ui-next/component/prompt/keybindings.ts
Normal file
13
apps/cli/src/ui-next/component/prompt/keybindings.ts
Normal 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" },
|
||||
]
|
||||
108
apps/cli/src/ui-next/component/prompt/keyboard-handler.ts
Normal file
108
apps/cli/src/ui-next/component/prompt/keyboard-handler.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
390
apps/cli/src/ui-next/context/extension-logic.ts
Normal file
390
apps/cli/src/ui-next/context/extension-logic.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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?")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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("")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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]")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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]/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./OnboardingScreen.js"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
}))
|
||||
|
|
@ -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),
|
||||
}))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./tools.js"
|
||||
export * from "./views.js"
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -3,8 +3,6 @@
|
|||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue