Compare commits

...

21 commits

Author SHA1 Message Date
cte
530b67d2ba Fix tests 2026-01-07 11:24:59 -08:00
cte
616472f807 Remove debug logging 2026-01-07 11:18:20 -08:00
cte
303598f014 More progress 2026-01-07 11:13:37 -08:00
cte
3d7117bb7b More progress 2026-01-07 11:01:06 -08:00
cte
5ec80f94eb Fix tsc errors 2026-01-07 03:03:43 -08:00
cte
58793f4aea A few more tweaks 2026-01-07 03:01:37 -08:00
cte
8072a90a7d More progress 2026-01-07 02:13:01 -08:00
cte
e85362fb11 New task slash command 2026-01-07 01:34:27 -08:00
cte
b6f571cadf More progress 2026-01-06 23:09:38 -08:00
cte
c95706e345 Fix tsc errors 2026-01-06 21:35:14 -08:00
cte
246062c86f Fix tsc errors 2026-01-06 21:30:59 -08:00
cte
c9d52349d5 More progress 2026-01-06 21:28:27 -08:00
cte
a590932727 More progress 2026-01-06 16:22:03 -08:00
cte
262aaa52d1 More progress 2026-01-06 15:39:45 -08:00
cte
0a8f1a13c4 A few more fixes 2026-01-06 03:42:31 -08:00
cte
b4fe095bf1 Merge main 2026-01-06 03:28:44 -08:00
cte
930755e44a Change the text input to multiline 2026-01-06 03:18:45 -08:00
cte
ad6ce88583 Add file picker 2026-01-06 02:59:57 -08:00
cte
c137cf44c8 Fix the build 2026-01-06 01:31:03 -08:00
cte
53ad2e1d6a Add a TUI 2026-01-06 00:49:28 -08:00
cte
a81438de3e Add a cli installer 2026-01-05 16:29:41 -08:00
159 changed files with 12384 additions and 1413 deletions

67
.roo/rules-debug/cli.md Normal file
View file

@ -0,0 +1,67 @@
# CLI Debugging with File-Based Logging
When debugging the CLI, `console.log` will break the TUI (Terminal User Interface). Use file-based logging to capture debug output without interfering with the application's display.
## File-Based Logging Strategy
1. **Write logs to a temporary file instead of console**:
- Create a log file at a known location, e.g., `/tmp/roo-cli-debug.log`
- Use `fs.appendFileSync()` to write timestamped log entries
- Example logging utility:
```typescript
import fs from "fs"
const DEBUG_LOG = "/tmp/roo-cli-debug.log"
function debugLog(message: string, data?: unknown) {
const timestamp = new Date().toISOString()
const entry = data
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
: `[${timestamp}] ${message}\n`
fs.appendFileSync(DEBUG_LOG, entry)
}
```
2. **Clear the log file before each debugging session**:
- Run `echo "" > /tmp/roo-cli-debug.log` or use `fs.writeFileSync(DEBUG_LOG, "")` at app startup during debugging
## Iterative Debugging Workflow
Follow this feedback loop to systematically narrow down issues:
1. **Add targeted logging** at suspected problem areas based on your hypotheses
2. **Instruct the user** to reproduce the issue using the CLI normally
3. **Read the log file** after the user completes testing:
- Run `cat /tmp/roo-cli-debug.log` to retrieve the captured output
4. **Analyze the log output** to gather clues about:
- Execution flow and timing
- Variable values at key points
- Which code paths were taken
- Error conditions or unexpected states
5. **Refine your logging** based on findings—add more detail where needed, remove noise
6. **Ask the user to test again** with updated logging
7. **Repeat** until the root cause is identified
## Best Practices
- Log entry/exit points of functions under investigation
- Include relevant variable values and state information
- Use descriptive prefixes to categorize logs: `[STATE]`, `[EVENT]`, `[ERROR]`, `[FLOW]`
- Log both the "happy path" and error handling branches
- When dealing with async operations, log before and after `await` statements
- For user interactions, log the received input and the resulting action
## Example Debug Session
```typescript
// Add logging to investigate a picker selection issue
debugLog("[FLOW] PickerSelect onSelect called", { selectedIndex, item })
debugLog("[STATE] Current selection state", { currentValue, isOpen })
// After async operation
const result = await fetchOptions()
debugLog("[FLOW] fetchOptions completed", { resultCount: result.length })
```
Then ask: "Please reproduce the issue by [specific steps]. When you're done, let me know and I'll analyze the debug logs."

View file

@ -71,7 +71,13 @@ By default, the CLI prompts for approval before executing actions:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
roo "What is this project?" --workspace ~/Documents/my-project
roo ~/Documents/my-project -P "What is this project?"
```
You can also run without a prompt and enter it interactively in TUI mode:
```bash
roo ~/Documents/my-project
```
In interactive mode:
@ -86,7 +92,7 @@ In interactive mode:
For automation and scripts, use `-y` to auto-approve all actions:
```bash
roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project
roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
```
In non-interactive mode:
@ -99,7 +105,8 @@ In non-interactive mode:
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |

View file

@ -260,7 +260,7 @@ print_success() {
echo ""
echo " ${BOLD}Example:${NC}"
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
echo " roo \"What is this project?\" --workspace ~/my-project"
echo " roo ~/my-project -P \"What is this project?\""
echo ""
}

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/cli",
"version": "0.1.0",
"version": "0.2.2",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
@ -14,22 +14,30 @@
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"dev": "tsup --watch",
"start": "node dist/index.js",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@inkjs/ui": "^2.0.0",
"@roo-code/core": "workspace:^",
"@roo-code/types": "workspace:^",
"@roo-code/vscode-shim": "workspace:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0"
"commander": "^12.1.0",
"fuzzysort": "^3.1.0",
"ink": "^6.6.0",
"react": "^19.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"@types/react": "^19.1.6",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"tsup": "^8.4.0",
"typescript": "5.8.3",
"vitest": "^3.2.3"
}
}

View file

@ -130,7 +130,7 @@ create_tarball() {
info "Copying CLI files..."
cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
# Create package.json for npm install (only runtime dependencies)
# Create package.json for npm install (runtime dependencies that can't be bundled)
info "Creating package.json..."
node -e "
const pkg = require('$CLI_DIR/package.json');
@ -139,7 +139,11 @@ create_tarball() {
version: pkg.version,
type: 'module',
dependencies: {
commander: pkg.dependencies.commander
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
'commander': pkg.dependencies.commander,
'ink': pkg.dependencies.ink,
'react': pkg.dependencies.react,
'zustand': pkg.dependencies.zustand
}
};
console.log(JSON.stringify(newPkg, null, 2));
@ -197,6 +201,9 @@ WRAPPER_EOF
# Create version file
echo "$VERSION" > "$RELEASE_DIR/VERSION"
# Create empty .env file to suppress dotenvx warnings
touch "$RELEASE_DIR/.env"
# Create tarball
info "Creating tarball..."
cd "$REPO_ROOT"

View file

@ -0,0 +1,187 @@
/**
* Tests for Escape key cancel/pause functionality
*
* When the CLI is in a loading state (streaming LLM API calls),
* pressing Escape should send a "cancelTask" message to the extension,
* similar to the Cancel button in the webview-ui.
*/
describe("Escape key cancel behavior", () => {
describe("escape key detection logic", () => {
/**
* Simulates the escape key handling logic from App.tsx
*
* @param key - The key object from ink's useInput
* @param isLoading - Whether the app is currently loading (streaming)
* @param hasHostRef - Whether the extension host reference is available
* @param isPickerOpen - Whether an autocomplete picker is currently open
* @returns An object describing what action should be taken
*/
const handleEscapeKey = (
key: { escape: boolean },
isLoading: boolean,
hasHostRef: boolean,
isPickerOpen: boolean,
): { shouldCancel: boolean; reason?: string } => {
if (!key.escape) {
return { shouldCancel: false, reason: "Not escape key" }
}
if (!isLoading) {
return { shouldCancel: false, reason: "Not in loading state" }
}
if (!hasHostRef) {
return { shouldCancel: false, reason: "No host reference" }
}
if (isPickerOpen) {
// Let picker handle escape first
return { shouldCancel: false, reason: "Picker is open" }
}
return { shouldCancel: true }
}
it("should cancel task when escape is pressed during loading", () => {
const result = handleEscapeKey(
{ escape: true },
true, // isLoading
true, // hasHostRef
false, // isPickerOpen
)
expect(result.shouldCancel).toBe(true)
})
it("should not cancel when not loading", () => {
const result = handleEscapeKey(
{ escape: true },
false, // isLoading - not loading
true, // hasHostRef
false, // isPickerOpen
)
expect(result.shouldCancel).toBe(false)
expect(result.reason).toBe("Not in loading state")
})
it("should not cancel when host reference is not available", () => {
const result = handleEscapeKey(
{ escape: true },
true, // isLoading
false, // hasHostRef - no host reference
false, // isPickerOpen
)
expect(result.shouldCancel).toBe(false)
expect(result.reason).toBe("No host reference")
})
it("should not cancel when picker is open", () => {
const result = handleEscapeKey(
{ escape: true },
true, // isLoading
true, // hasHostRef
true, // isPickerOpen - picker is open
)
expect(result.shouldCancel).toBe(false)
expect(result.reason).toBe("Picker is open")
})
it("should not do anything for non-escape keys", () => {
const result = handleEscapeKey(
{ escape: false }, // Not escape key
true, // isLoading
true, // hasHostRef
false, // isPickerOpen
)
expect(result.shouldCancel).toBe(false)
expect(result.reason).toBe("Not escape key")
})
})
describe("cancel message format", () => {
it("should create the correct message format for cancelTask", () => {
// The message sent to extension should match webview-ui format
const cancelMessage = { type: "cancelTask" }
expect(cancelMessage).toEqual({ type: "cancelTask" })
expect(cancelMessage.type).toBe("cancelTask")
})
it("should match the webview-ui cancel message format", () => {
// From webview-ui/src/components/chat/ChatView.tsx line 750:
// vscode.postMessage({ type: "cancelTask" })
const webviewCancelMessage = { type: "cancelTask" }
const cliCancelMessage = { type: "cancelTask" }
expect(cliCancelMessage).toEqual(webviewCancelMessage)
})
})
describe("loading state scenarios", () => {
/**
* The isLoading state in the CLI store represents:
* - Active API request in progress
* - Task is streaming responses
* - Agent is "thinking" or processing
*/
it("should identify loading state during agent response", () => {
const view = "AgentResponse"
const isLoading = true
// During agent response, cancel should be available
expect(view).toBe("AgentResponse")
expect(isLoading).toBe(true)
})
it("should identify loading state during tool use", () => {
const view = "ToolUse"
const isLoading = true
// During tool use, cancel should be available
expect(view).toBe("ToolUse")
expect(isLoading).toBe(true)
})
it("should not identify loading state during user input", () => {
const view = "UserInput"
const isLoading = false
// During user input, no need for cancel
expect(view).toBe("UserInput")
expect(isLoading).toBe(false)
})
})
describe("cancel behavior expectations", () => {
it("should pause the task (not terminate)", () => {
// The cancelTask message pauses the task, allowing the user to:
// 1. Review the current state
// 2. Provide additional input
// 3. Resume the task by typing something
const cancelBehavior = {
action: "pause",
terminates: false,
allowsResume: true,
resumeMethod: "user provides input",
}
expect(cancelBehavior.action).toBe("pause")
expect(cancelBehavior.terminates).toBe(false)
expect(cancelBehavior.allowsResume).toBe(true)
})
it("should allow resuming by typing after cancel", () => {
// After cancel, the user can resume by typing a message
const postCancelState = {
isLoading: false, // Loading stops
canTypeMessage: true, // User can type
messageResumesTask: true, // Typing resumes the task
}
expect(postCancelState.isLoading).toBe(false)
expect(postCancelState.canTypeMessage).toBe(true)
expect(postCancelState.messageResumesTask).toBe(true)
})
})
})

View file

@ -0,0 +1,568 @@
/**
* Tests for MultilineTextInput component
*/
describe("MultilineTextInput", () => {
describe("cursor position calculations", () => {
// Test the getCursorPosition logic
const 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 }
}
// Test the getIndexFromPosition logic
const 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
}
it("should calculate cursor position for single line", () => {
const value = "hello"
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
expect(getCursorPosition(value, 2)).toEqual({ line: 0, col: 2 })
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 })
})
it("should calculate cursor position for multiple lines", () => {
const value = "hello\nworld"
// "hello" is 5 chars, newline at index 5
// "world" starts at index 6
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) // End of first line
expect(getCursorPosition(value, 6)).toEqual({ line: 1, col: 0 }) // Start of second line
expect(getCursorPosition(value, 8)).toEqual({ line: 1, col: 2 }) // Middle of second line
expect(getCursorPosition(value, 11)).toEqual({ line: 1, col: 5 }) // End of second line
})
it("should calculate cursor position for three lines", () => {
const value = "foo\nbar\nbaz"
// "foo" = 3 chars, newline at 3
// "bar" starts at 4, ends at 6, newline at 7
// "baz" starts at 8
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
expect(getCursorPosition(value, 3)).toEqual({ line: 0, col: 3 })
expect(getCursorPosition(value, 4)).toEqual({ line: 1, col: 0 })
expect(getCursorPosition(value, 7)).toEqual({ line: 1, col: 3 })
expect(getCursorPosition(value, 8)).toEqual({ line: 2, col: 0 })
expect(getCursorPosition(value, 11)).toEqual({ line: 2, col: 3 })
})
it("should calculate index from position for single line", () => {
const value = "hello"
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
expect(getIndexFromPosition(value, 0, 2)).toBe(2)
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
})
it("should calculate index from position for multiple lines", () => {
const value = "hello\nworld"
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
expect(getIndexFromPosition(value, 1, 0)).toBe(6)
expect(getIndexFromPosition(value, 1, 2)).toBe(8)
expect(getIndexFromPosition(value, 1, 5)).toBe(11)
})
it("should clamp column to line length", () => {
const value = "hi\nworld"
// First line "hi" is only 2 chars, requesting col 5 should clamp to 2
expect(getIndexFromPosition(value, 0, 5)).toBe(2)
})
})
describe("line splitting", () => {
it("should split empty string into single empty line", () => {
const value = ""
const lines = value.split("\n")
expect(lines).toEqual([""])
})
it("should split single line correctly", () => {
const value = "hello world"
const lines = value.split("\n")
expect(lines).toEqual(["hello world"])
})
it("should split multiple lines correctly", () => {
const value = "foo\nbar\nbaz"
const lines = value.split("\n")
expect(lines).toEqual(["foo", "bar", "baz"])
})
it("should handle trailing newline", () => {
const value = "foo\nbar\n"
const lines = value.split("\n")
expect(lines).toEqual(["foo", "bar", ""])
})
it("should handle empty lines in middle", () => {
const value = "foo\n\nbaz"
const lines = value.split("\n")
expect(lines).toEqual(["foo", "", "baz"])
})
})
describe("line normalization", () => {
const normalizeLineEndings = (text: string): string => {
if (text == null) return ""
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
}
it("should normalize CRLF to LF", () => {
expect(normalizeLineEndings("hello\r\nworld")).toBe("hello\nworld")
})
it("should normalize CR to LF", () => {
expect(normalizeLineEndings("hello\rworld")).toBe("hello\nworld")
})
it("should leave LF unchanged", () => {
expect(normalizeLineEndings("hello\nworld")).toBe("hello\nworld")
})
it("should handle null/undefined", () => {
expect(normalizeLineEndings(null as unknown as string)).toBe("")
expect(normalizeLineEndings(undefined as unknown as string)).toBe("")
})
it("should handle mixed line endings", () => {
expect(normalizeLineEndings("a\r\nb\rc\nd")).toBe("a\nb\nc\nd")
})
})
describe("key binding behavior", () => {
it("should detect Ctrl+Enter for newline insertion", () => {
const isNewlineKey = (key: { return: boolean; ctrl: boolean }) => key.return && key.ctrl
expect(isNewlineKey({ return: true, ctrl: true })).toBe(true)
expect(isNewlineKey({ return: true, ctrl: false })).toBe(false)
expect(isNewlineKey({ return: false, ctrl: true })).toBe(false)
})
it("should detect Enter for submit", () => {
const isSubmitKey = (key: { return: boolean; ctrl: boolean }) => key.return && !key.ctrl
expect(isSubmitKey({ return: true, ctrl: false })).toBe(true)
expect(isSubmitKey({ return: true, ctrl: true })).toBe(false)
expect(isSubmitKey({ return: false, ctrl: false })).toBe(false)
})
})
describe("newline insertion", () => {
it("should insert newline at cursor position", () => {
const value = "hello"
const cursorIndex = 2
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
expect(newValue).toBe("he\nllo")
})
it("should insert newline at end", () => {
const value = "hello"
const cursorIndex = 5
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
expect(newValue).toBe("hello\n")
})
it("should insert newline at start", () => {
const value = "hello"
const cursorIndex = 0
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
expect(newValue).toBe("\nhello")
})
})
describe("backspace behavior", () => {
it("should delete character before cursor", () => {
const value = "hello"
const cursorIndex = 3
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
expect(newValue).toBe("helo")
})
it("should delete newline character (merge lines)", () => {
const value = "hello\nworld"
const cursorIndex = 6 // Start of "world" line
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
expect(newValue).toBe("helloworld")
})
it("should do nothing at start of input", () => {
const value = "hello"
const cursorIndex = 0
// In real implementation, we check if cursorIndex > 0
if (cursorIndex > 0) {
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
expect(newValue).not.toBe(value)
}
// At position 0, backspace does nothing
expect(value).toBe("hello")
})
})
describe("arrow key navigation", () => {
describe("up arrow", () => {
it("should move to previous line preserving column", () => {
const value = "hello\nworld"
const getCursorPosition = (v: string, i: number) => {
const lines = v.split("\n")
let pos = 0
for (let li = 0; li < lines.length; li++) {
const line = lines[li]!
const lineEnd = pos + line.length
if (i <= lineEnd) {
return { line: li, col: i - pos }
}
pos = lineEnd + 1
}
return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
}
const getIndexFromPosition = (v: string, line: number, col: number) => {
const lines = v.split("\n")
let index = 0
for (let i = 0; i < line && i < lines.length; i++) {
index += lines[i]!.length + 1
}
const targetLine = lines[line] || ""
index += Math.min(col, targetLine.length)
return index
}
// Cursor at "world"[2] (index 8)
const cursorIndex = 8
const { line, col } = getCursorPosition(value, cursorIndex)
expect(line).toBe(1)
expect(col).toBe(2)
// Move up: should go to line 0, same column
const targetLine = 0
const newIndex = getIndexFromPosition(value, targetLine, col)
expect(newIndex).toBe(2) // "he|llo"
})
it("should clamp column if target line is shorter", () => {
const value = "hi\nworld"
const getIndexFromPosition = (v: string, line: number, col: number) => {
const lines = v.split("\n")
let index = 0
for (let i = 0; i < line && i < lines.length; i++) {
index += lines[i]!.length + 1
}
const targetLine = lines[line] || ""
index += Math.min(col, targetLine.length)
return index
}
// Cursor at "world"[4] (index 7)
// Moving up to "hi" which is only 2 chars, should clamp to col 2
const targetLine = 0
const col = 4
const newIndex = getIndexFromPosition(value, targetLine, col)
expect(newIndex).toBe(2) // End of "hi"
})
})
describe("down arrow", () => {
it("should move to next line preserving column", () => {
const value = "hello\nworld"
const getIndexFromPosition = (v: string, line: number, col: number) => {
const lines = v.split("\n")
let index = 0
for (let i = 0; i < line && i < lines.length; i++) {
index += lines[i]!.length + 1
}
const targetLine = lines[line] || ""
index += Math.min(col, targetLine.length)
return index
}
// Cursor at "hello"[2] (index 2)
const col = 2
const targetLine = 1
const newIndex = getIndexFromPosition(value, targetLine, col)
expect(newIndex).toBe(8) // "wo|rld"
})
})
describe("left/right arrows", () => {
it("should move left by 1", () => {
const cursorIndex = 5
const newIndex = Math.max(0, cursorIndex - 1)
expect(newIndex).toBe(4)
})
it("should not move left past 0", () => {
const cursorIndex = 0
const newIndex = Math.max(0, cursorIndex - 1)
expect(newIndex).toBe(0)
})
it("should move right by 1", () => {
const value = "hello"
const cursorIndex = 2
const newIndex = Math.min(value.length, cursorIndex + 1)
expect(newIndex).toBe(3)
})
it("should not move right past end", () => {
const value = "hello"
const cursorIndex = 5
const newIndex = Math.min(value.length, cursorIndex + 1)
expect(newIndex).toBe(5)
})
})
})
})
describe("word-boundary line wrapping", () => {
// Represents a visual row after wrapping a logical line
interface VisualRow {
text: string
logicalLineIndex: number
isFirstRowOfLine: boolean
startCol: number
}
/**
* 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) {
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
}
it("should not wrap text shorter than available width", () => {
const rows = wrapLine("hello world", 0, 20)
expect(rows).toHaveLength(1)
expect(rows[0]!.text).toBe("hello world")
expect(rows[0]!.isFirstRowOfLine).toBe(true)
expect(rows[0]!.startCol).toBe(0)
})
it("should wrap at word boundary when possible", () => {
const rows = wrapLine("hello world foo", 0, 10)
expect(rows).toHaveLength(2)
expect(rows[0]!.text).toBe("hello ")
expect(rows[0]!.isFirstRowOfLine).toBe(true)
expect(rows[0]!.startCol).toBe(0)
expect(rows[1]!.text).toBe("world foo")
expect(rows[1]!.isFirstRowOfLine).toBe(false)
expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars
})
it("should break mid-word when no space found", () => {
const rows = wrapLine("superlongwordwithoutspaces", 0, 10)
expect(rows).toHaveLength(3)
// Falls back to breaking at availableWidth when no space is found
expect(rows[0]!.text).toBe("superlongw")
expect(rows[1]!.text).toBe("ordwithout")
expect(rows[2]!.text).toBe("spaces")
})
it("should handle multiple word wraps", () => {
const rows = wrapLine("one two three four five six", 0, 8)
expect(rows).toHaveLength(4)
expect(rows[0]!.text).toBe("one two ")
expect(rows[1]!.text).toBe("three ")
expect(rows[2]!.text).toBe("four ")
expect(rows[3]!.text).toBe("five six")
})
it("should preserve logical line index", () => {
const rows = wrapLine("hello world", 2, 6)
expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true)
})
it("should handle empty string", () => {
const rows = wrapLine("", 0, 10)
expect(rows).toHaveLength(1)
expect(rows[0]!.text).toBe("")
})
it("should handle string that exactly matches width", () => {
const rows = wrapLine("hello", 0, 5)
expect(rows).toHaveLength(1)
expect(rows[0]!.text).toBe("hello")
})
it("should track correct startCol for wrapped rows", () => {
const rows = wrapLine("aa bb cc dd", 0, 5)
// "aa bb cc dd" = 11 chars, width = 5
// "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10)
// Search backwards from index 4:
// index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3
// Row 0: "aa " (3 chars), startCol=0
// Remaining: "bb cc dd" (8 chars), startCol=3
// Search backwards from index 4:
// "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)...
// index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3
// Row 1: "bb " (3 chars), startCol=3
// Remaining: "cc dd" (5 chars), startCol=6
// 5 <= 5, fits in one row
// Row 2: "cc dd", startCol=6
expect(rows).toHaveLength(3)
expect(rows[0]!.text).toBe("aa ")
expect(rows[0]!.startCol).toBe(0)
expect(rows[1]!.text).toBe("bb ")
expect(rows[1]!.startCol).toBe(3)
expect(rows[2]!.text).toBe("cc dd")
expect(rows[2]!.startCol).toBe(6)
})
})
describe("multi-line history integration", () => {
it("should store multi-line entries with newlines", () => {
const entry = "foo\nbar\nbaz"
expect(entry.includes("\n")).toBe(true)
expect(entry.split("\n").length).toBe(3)
})
it("should restore multi-line entries correctly", () => {
const storedEntry = "foo\nbar\nbaz"
const lines = storedEntry.split("\n")
expect(lines).toEqual(["foo", "bar", "baz"])
})
})
describe("cursor overflow prevention", () => {
/**
* Tests the logic that prevents visual shift when cursor is at the end
* of a max-width row. Adding a cursor space character would overflow
* the terminal width, causing text to shift left.
*/
it("should detect when cursor space would overflow", () => {
// Simulates the overflow detection logic from renderVisualRow
const checkWouldOverflow = (
columns: number | undefined,
cursorAtEnd: boolean,
prefixLen: number,
textLen: number,
): boolean => {
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
}
// Terminal width 80, prefix "> " (2 chars), text 78 chars = exactly full
// Adding cursor space would make it 81 chars -> overflow
expect(checkWouldOverflow(80, true, 2, 78)).toBe(true)
// Same scenario but cursor not at end -> no overflow issue
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
// Text shorter than max width -> no overflow
expect(checkWouldOverflow(80, true, 2, 50)).toBe(false)
// No columns specified -> no overflow detection
expect(checkWouldOverflow(undefined, true, 2, 78)).toBe(false)
// Continuation line with shorter indent
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
// Exactly at boundary (prefixLen + textLen + 1 === columns)
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
// One character over would overflow
expect(checkWouldOverflow(80, true, 3, 77)).toBe(true)
})
it("should not overflow when cursor is in the middle of text", () => {
const checkWouldOverflow = (
columns: number | undefined,
cursorAtEnd: boolean,
prefixLen: number,
textLen: number,
): boolean => {
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
}
// Cursor in middle of max-width row - no extra space added, no overflow
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
expect(checkWouldOverflow(80, false, 2, 100)).toBe(false)
})
it("should correctly identify cursor at end of row", () => {
// Simulates the cursorAtEnd check
const isCursorAtEnd = (cursorColInRow: number, textLen: number): boolean => {
return cursorColInRow >= textLen
}
expect(isCursorAtEnd(5, 5)).toBe(true) // cursor at position 5, text length 5
expect(isCursorAtEnd(10, 5)).toBe(true) // cursor beyond text (clamped case)
expect(isCursorAtEnd(4, 5)).toBe(false) // cursor before end
expect(isCursorAtEnd(0, 0)).toBe(true) // empty text, cursor at start/end
})
})

View file

@ -0,0 +1,485 @@
/**
* Unit tests for ScrollArea component reducer logic
*/
// Since we can't easily test React components without a proper Ink test setup,
// we'll test the reducer logic that powers the ScrollArea behavior.
interface ScrollAreaState {
innerHeight: number
height: number
scrollTop: number
autoScroll: boolean
}
/**
* Calculate scrollbar handle position and size
*/
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: "SET_AUTO_SCROLL"; autoScroll: boolean }
// Copy of the reducer from ScrollArea.tsx for testing
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 (state.autoScroll && action.innerHeight > state.innerHeight) {
return {
...state,
innerHeight: action.innerHeight,
scrollTop: newMaxScroll,
}
}
return {
...state,
innerHeight: action.innerHeight,
scrollTop: Math.min(state.scrollTop, newMaxScroll),
}
}
case "SET_HEIGHT": {
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
if (state.autoScroll) {
return {
...state,
height: action.height,
scrollTop: newMaxScroll,
}
}
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)
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)
return {
...state,
scrollTop: newScrollTop,
autoScroll: newScrollTop >= maxScroll,
}
}
case "SCROLL_TO_BOTTOM":
return {
...state,
scrollTop: maxScroll,
autoScroll: true,
}
case "SET_AUTO_SCROLL":
return {
...state,
autoScroll: action.autoScroll,
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
}
default:
return state
}
}
describe("ScrollArea reducer", () => {
const initialState: ScrollAreaState = {
innerHeight: 0,
height: 10,
scrollTop: 0,
autoScroll: true,
}
describe("SET_INNER_HEIGHT", () => {
it("should update inner height", () => {
const state = reducer(initialState, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
expect(state.innerHeight).toBe(20)
})
it("should auto-scroll to bottom when content grows and autoScroll is enabled", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 15,
autoScroll: true,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
expect(newState.innerHeight).toBe(25)
// maxScroll = 25 - 10 = 15
expect(newState.scrollTop).toBe(15)
})
it("should NOT auto-scroll when autoScroll is disabled", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 15,
scrollTop: 3,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
expect(newState.innerHeight).toBe(25)
expect(newState.scrollTop).toBe(3) // Unchanged
})
it("should NOT auto-scroll when content grows if autoScroll is disabled (picker use case)", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 5,
scrollTop: 0,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
expect(newState.innerHeight).toBe(20)
// scrollTop should remain at 0, not jump to bottom
expect(newState.scrollTop).toBe(0)
})
it("should clamp scrollTop when content shrinks", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 30,
scrollTop: 15,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 15 })
// maxScroll = 15 - 10 = 5, scrollTop was 15 which is > 5
expect(newState.scrollTop).toBe(5)
})
})
describe("SET_HEIGHT", () => {
it("should update viewport height", () => {
const state: ScrollAreaState = {
...initialState,
innerHeight: 20,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
expect(newState.height).toBe(15)
})
it("should scroll to bottom when autoScroll is enabled and viewport changes", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20, // at bottom
autoScroll: true,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
// maxScroll = 30 - 15 = 15
expect(newState.scrollTop).toBe(15)
})
it("should clamp scrollTop when viewport grows", () => {
const state: ScrollAreaState = {
innerHeight: 20,
height: 10,
scrollTop: 10, // maxScroll was 10
autoScroll: false,
}
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
// maxScroll = 20 - 15 = 5
expect(newState.scrollTop).toBe(5)
})
})
describe("SCROLL_DOWN", () => {
it("should scroll down by 1 by default", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(6)
})
it("should scroll down by specified amount", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 5 })
expect(newState.scrollTop).toBe(10)
})
it("should not scroll past maxScroll", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 18,
autoScroll: false,
}
// maxScroll = 30 - 10 = 20
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 10 })
expect(newState.scrollTop).toBe(20)
})
it("should re-enable autoScroll when reaching bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 19,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(20)
expect(newState.autoScroll).toBe(true)
})
})
describe("SCROLL_UP", () => {
it("should scroll up by 1 by default", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 10,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP" })
expect(newState.scrollTop).toBe(9)
})
it("should scroll up by specified amount", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 10,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP", amount: 5 })
expect(newState.scrollTop).toBe(5)
})
it("should not scroll past 0", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 3,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_UP", amount: 10 })
expect(newState.scrollTop).toBe(0)
})
it("should disable autoScroll when scrolling up from bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20, // at bottom
autoScroll: true,
}
const newState = reducer(state, { type: "SCROLL_UP" })
expect(newState.scrollTop).toBe(19)
expect(newState.autoScroll).toBe(false)
})
})
describe("SCROLL_TO_BOTTOM", () => {
it("should scroll to bottom and enable autoScroll", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
expect(newState.scrollTop).toBe(20) // maxScroll
expect(newState.autoScroll).toBe(true)
})
})
describe("SET_AUTO_SCROLL", () => {
it("should enable autoScroll and scroll to bottom", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 5,
autoScroll: false,
}
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: true })
expect(newState.autoScroll).toBe(true)
expect(newState.scrollTop).toBe(20) // scrolled to bottom
})
it("should disable autoScroll without changing scrollTop", () => {
const state: ScrollAreaState = {
innerHeight: 30,
height: 10,
scrollTop: 20,
autoScroll: true,
}
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: false })
expect(newState.autoScroll).toBe(false)
expect(newState.scrollTop).toBe(20)
})
})
describe("edge cases", () => {
it("should handle content smaller than viewport", () => {
const state: ScrollAreaState = {
innerHeight: 5, // smaller than viewport
height: 10,
scrollTop: 0,
autoScroll: true,
}
const downState = reducer(state, { type: "SCROLL_DOWN" })
expect(downState.scrollTop).toBe(0) // maxScroll is 0
const bottomState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
expect(bottomState.scrollTop).toBe(0)
})
it("should handle empty content", () => {
const state: ScrollAreaState = {
innerHeight: 0,
height: 10,
scrollTop: 0,
autoScroll: true,
}
const newState = reducer(state, { type: "SCROLL_DOWN" })
expect(newState.scrollTop).toBe(0)
})
})
})
describe("calculateScrollbar", () => {
it("should return full height handle when content fits in viewport", () => {
const result = calculateScrollbar(10, 5, 0)
expect(result.handleHeight).toBe(10)
expect(result.handleStart).toBe(0)
expect(result.maxScroll).toBe(0)
})
it("should return full height handle when content equals viewport", () => {
const result = calculateScrollbar(10, 10, 0)
expect(result.handleHeight).toBe(10)
expect(result.handleStart).toBe(0)
expect(result.maxScroll).toBe(0)
})
it("should calculate handle height proportional to content ratio", () => {
// Viewport is half of content, handle should be ~half of viewport
const result = calculateScrollbar(10, 20, 0)
expect(result.handleHeight).toBe(5) // 10 / 20 * 10 = 5
expect(result.maxScroll).toBe(10)
})
it("should position handle at top when scrollTop is 0", () => {
const result = calculateScrollbar(10, 20, 0)
expect(result.handleStart).toBe(0)
})
it("should position handle at bottom when scrolled to max", () => {
// Viewport 10, content 20, maxScroll = 10
// Handle height = 5, track space = 10 - 5 = 5
// At max scroll, handle should be at position 5
const result = calculateScrollbar(10, 20, 10)
expect(result.handleStart).toBe(5)
})
it("should position handle in middle when scrolled halfway", () => {
// Viewport 10, content 20, maxScroll = 10
// Handle height = 5, track space = 5
// At scroll 5 (50%), handle should be at position 2-3
const result = calculateScrollbar(10, 20, 5)
expect(result.handleStart).toBe(3) // Math.round(0.5 * 5) = 3
})
it("should enforce minimum handle height of 1", () => {
// Very large content relative to viewport
const result = calculateScrollbar(10, 1000, 0)
expect(result.handleHeight).toBe(1) // Math.max(1, Math.round(10/1000 * 10)) = 1
})
it("should handle small viewports", () => {
const result = calculateScrollbar(3, 10, 0)
expect(result.handleHeight).toBe(1) // Math.round(3/10 * 3) = 1
expect(result.maxScroll).toBe(7)
})
it("should handle edge case where scrollTop exceeds maxScroll", () => {
// This shouldn't happen in practice, but test for robustness
const result = calculateScrollbar(10, 20, 15) // maxScroll is 10
// scrollRatio = 15/10 = 1.5, but handleStart should be clamped by trackSpace
expect(result.handleStart).toBe(8) // Math.round(1.5 * 5) = 8 (will be past track but shows calculation)
})
})
/**
* Helper function that mirrors the scrollbar visibility logic from ScrollArea.tsx
* This is used to test the visibility behavior without needing to render the component.
*/
function shouldShowScrollbar(showScrollbar: boolean, maxScroll: number, isActive: boolean): boolean {
// 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
return showScrollbar && (maxScroll > 0 || isActive)
}
describe("scrollbar visibility", () => {
it("should show scrollbar when there is content to scroll (regardless of focus)", () => {
// When maxScroll > 0, scrollbar should show regardless of isActive
expect(shouldShowScrollbar(true, 10, true)).toBe(true)
expect(shouldShowScrollbar(true, 10, false)).toBe(true)
})
it("should show scrollbar when focused, even if nothing to scroll", () => {
// When isActive is true but maxScroll is 0, scrollbar should show for focus indication
expect(shouldShowScrollbar(true, 0, true)).toBe(true)
})
it("should hide scrollbar when not focused and nothing to scroll", () => {
// Only hide when both: not focused AND nothing to scroll
expect(shouldShowScrollbar(true, 0, false)).toBe(false)
})
it("should respect showScrollbar prop", () => {
// When showScrollbar is false, never show scrollbar
expect(shouldShowScrollbar(false, 10, true)).toBe(false)
expect(shouldShowScrollbar(false, 0, true)).toBe(false)
expect(shouldShowScrollbar(false, 10, false)).toBe(false)
expect(shouldShowScrollbar(false, 0, false)).toBe(false)
})
})

View file

@ -0,0 +1,197 @@
import { describe, it, expect, vi } from "vitest"
import {
createFileTrigger,
toFileResult,
type FileResult,
} from "../../ui/components/autocomplete/triggers/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 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 detect @ trigger even with empty query", () => {
const result = trigger.detectTrigger("hello @")
expect(result).toEqual({
query: "",
triggerIndex: 6,
})
})
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 ")
})
})
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")
})
})
})
})

View file

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

View file

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

View file

@ -1,8 +1,13 @@
// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
import { EventEmitter } from "events"
import type { ProviderName } from "@roo-code/types"
import fs from "fs"
import os from "os"
import path from "path"
import type { ProviderName, WebviewMessage } from "@roo-code/types"
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
vi.mock("@roo-code/vscode-shim", () => ({
createVSCodeAPI: vi.fn(() => ({
@ -369,15 +374,15 @@ describe("ExtensionHost", () => {
const emitSpy = vi.spyOn(host, "emit")
// Queue messages before ready
host.sendToExtension({ type: "test1" })
host.sendToExtension({ type: "test2" })
host.sendToExtension({ type: "requestModes" })
host.sendToExtension({ type: "requestCommands" })
// Mark ready (should flush)
host.markWebviewReady()
// Check that webviewMessage events were emitted for pending messages
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
})
})
})
@ -385,7 +390,7 @@ describe("ExtensionHost", () => {
describe("sendToExtension", () => {
it("should queue message when webview not ready", () => {
const host = createTestHost()
const message = { type: "test" }
const message: WebviewMessage = { type: "requestModes" }
host.sendToExtension(message)
@ -396,7 +401,7 @@ describe("ExtensionHost", () => {
it("should emit webviewMessage event when webview is ready", () => {
const host = createTestHost()
const emitSpy = vi.spyOn(host, "emit")
const message = { type: "test" }
const message: WebviewMessage = { type: "requestModes" }
host.markWebviewReady()
host.sendToExtension(message)
@ -408,7 +413,7 @@ describe("ExtensionHost", () => {
const host = createTestHost()
host.markWebviewReady()
host.sendToExtension({ type: "test" })
host.sendToExtension({ type: "requestModes" })
const pending = getPrivate<unknown[]>(host, "pendingMessages")
expect(pending).toHaveLength(0)
@ -433,24 +438,6 @@ describe("ExtensionHost", () => {
expect(handleMsgUpdatedSpy).toHaveBeenCalled()
})
it("should route action messages to handleActionMessage", () => {
const host = createTestHost()
const handleActionSpy = spyOnPrivate(host, "handleActionMessage")
callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" })
expect(handleActionSpy).toHaveBeenCalled()
})
it("should route invoke messages to handleInvokeMessage", () => {
const host = createTestHost()
const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage")
callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" })
expect(handleInvokeSpy).toHaveBeenCalled()
})
})
describe("handleSayMessage", () => {
@ -810,7 +797,7 @@ describe("ExtensionHost", () => {
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
// Should show prompt with timeout hint
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s"))
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 60s"))
})
})
@ -1161,4 +1148,207 @@ describe("ExtensionHost", () => {
vi.useRealTimers()
})
})
describe("handleStateMessage - mode tracking", () => {
let host: ExtensionHost
beforeEach(() => {
host = createTestHost({
mode: "code",
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Mock process.stdout.write which is used by output()
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should track current mode when state updates with a mode", () => {
// Initial state update establishes current mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// Second state update should update tracked mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("architect")
})
it("should not change current mode when state has no mode", () => {
// Initial state update establishes current mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// State without mode should not change tracked mode
callPrivate(host, "handleStateMessage", { type: "state", state: { clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
})
it("should track current mode across multiple changes", () => {
// Start with code mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// Change to architect
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("architect")
// Change to debug
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("debug")
// Another state update with debug
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("debug")
})
it("should not send updateSettings on mode change (CLI settings are applied once during runTask)", () => {
// This test ensures mode changes don't trigger automatic re-application of API settings.
// CLI settings are applied once during runTask() via updateSettings.
// Mode-specific provider profiles are handled by the extension's handleModeSwitch.
const sendToExtensionSpy = vi.spyOn(host, "sendToExtension")
// Initial state
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
sendToExtensionSpy.mockClear()
// Mode change should NOT trigger sendToExtension
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(sendToExtensionSpy).not.toHaveBeenCalled()
})
})
describe("ephemeral mode", () => {
describe("constructor", () => {
it("should store ephemeral option", () => {
const host = createTestHost({ ephemeral: true })
const options = getPrivate<ExtensionHostOptions>(host, "options")
expect(options.ephemeral).toBe(true)
})
it("should default ephemeral to undefined", () => {
const host = createTestHost()
const options = getPrivate<ExtensionHostOptions>(host, "options")
expect(options.ephemeral).toBeUndefined()
})
it("should initialize ephemeralStorageDir to null", () => {
const host = createTestHost({ ephemeral: true })
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
})
})
describe("createEphemeralStorageDir", () => {
let createdDirs: string[] = []
afterEach(async () => {
// Clean up any directories created during tests
for (const dir of createdDirs) {
try {
await fs.promises.rm(dir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
createdDirs = []
})
it("should create a directory in the system temp folder", async () => {
const host = createTestHost({ ephemeral: true })
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(tmpDir)
expect(tmpDir).toContain(os.tmpdir())
expect(tmpDir).toContain("roo-cli-")
expect(fs.existsSync(tmpDir)).toBe(true)
})
it("should create a unique directory each time", async () => {
const host = createTestHost({ ephemeral: true })
const dir1 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
const dir2 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(dir1, dir2)
expect(dir1).not.toBe(dir2)
expect(fs.existsSync(dir1)).toBe(true)
expect(fs.existsSync(dir2)).toBe(true)
})
it("should include timestamp and random id in directory name", async () => {
const host = createTestHost({ ephemeral: true })
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(tmpDir)
const dirName = path.basename(tmpDir)
// Format: roo-cli-{timestamp}-{randomId}
expect(dirName).toMatch(/^roo-cli-\d+-[a-z0-9]+$/)
})
})
describe("dispose - ephemeral cleanup", () => {
it("should clean up ephemeral storage directory on dispose", async () => {
const host = createTestHost({ ephemeral: true })
// Create the ephemeral directory
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
// Verify directory exists
expect(fs.existsSync(tmpDir)).toBe(true)
// Dispose the host
await host.dispose()
// Directory should be removed
expect(fs.existsSync(tmpDir)).toBe(false)
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
})
it("should not fail dispose if ephemeral directory doesn't exist", async () => {
const host = createTestHost({ ephemeral: true })
// Set a non-existent directory
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = "/non/existent/path/roo-cli-test"
// Dispose should not throw
await expect(host.dispose()).resolves.toBeUndefined()
})
it("should clean up ephemeral directory with contents", async () => {
const host = createTestHost({ ephemeral: true })
// Create the ephemeral directory with some content
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
// Add some files and subdirectories
await fs.promises.writeFile(path.join(tmpDir, "test.txt"), "test content")
await fs.promises.mkdir(path.join(tmpDir, "subdir"))
await fs.promises.writeFile(path.join(tmpDir, "subdir", "nested.txt"), "nested content")
// Verify content exists
expect(fs.existsSync(path.join(tmpDir, "test.txt"))).toBe(true)
expect(fs.existsSync(path.join(tmpDir, "subdir", "nested.txt"))).toBe(true)
// Dispose the host
await host.dispose()
// Directory and all contents should be removed
expect(fs.existsSync(tmpDir)).toBe(false)
})
it("should not clean up anything if not in ephemeral mode", async () => {
const host = createTestHost({ ephemeral: false })
// ephemeralStorageDir should be null
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
// Dispose should complete normally
await expect(host.dispose()).resolves.toBeUndefined()
})
})
})
})

View file

@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest"
import {
GLOBAL_COMMANDS,
getGlobalCommand,
getGlobalCommandsForAutocomplete,
type GlobalCommand,
type GlobalCommandAction,
} from "../globalCommands.js"
describe("globalCommands", () => {
describe("GLOBAL_COMMANDS", () => {
it("should contain the /new command", () => {
const newCommand = GLOBAL_COMMANDS.find((cmd) => cmd.name === "new")
expect(newCommand).toBeDefined()
expect(newCommand?.action).toBe("clearTask")
expect(newCommand?.description).toBe("Start a new task")
})
it("should have valid structure for all commands", () => {
for (const cmd of GLOBAL_COMMANDS) {
expect(cmd.name).toBeTruthy()
expect(typeof cmd.name).toBe("string")
expect(cmd.description).toBeTruthy()
expect(typeof cmd.description).toBe("string")
expect(cmd.action).toBeTruthy()
expect(typeof cmd.action).toBe("string")
}
})
})
describe("getGlobalCommand", () => {
it("should return the command when found", () => {
const cmd = getGlobalCommand("new")
expect(cmd).toBeDefined()
expect(cmd?.name).toBe("new")
expect(cmd?.action).toBe("clearTask")
})
it("should return undefined for unknown commands", () => {
const cmd = getGlobalCommand("unknown-command")
expect(cmd).toBeUndefined()
})
it("should be case-sensitive", () => {
const cmd = getGlobalCommand("NEW")
expect(cmd).toBeUndefined()
})
})
describe("getGlobalCommandsForAutocomplete", () => {
it("should return commands in autocomplete format", () => {
const commands = getGlobalCommandsForAutocomplete()
expect(commands.length).toBe(GLOBAL_COMMANDS.length)
for (const cmd of commands) {
expect(cmd.name).toBeTruthy()
expect(cmd.source).toBe("global")
expect(cmd.action).toBeTruthy()
}
})
it("should include the /new command with correct format", () => {
const commands = getGlobalCommandsForAutocomplete()
const newCommand = commands.find((cmd) => cmd.name === "new")
expect(newCommand).toBeDefined()
expect(newCommand?.description).toBe("Start a new task")
expect(newCommand?.source).toBe("global")
expect(newCommand?.action).toBe("clearTask")
})
it("should not include argumentHint for action commands", () => {
const commands = getGlobalCommandsForAutocomplete()
// Action commands don't have argument hints
for (const cmd of commands) {
expect(cmd).not.toHaveProperty("argumentHint")
}
})
})
describe("type safety", () => {
it("should have valid GlobalCommandAction types", () => {
// This test ensures the type is properly constrained
const validActions: GlobalCommandAction[] = ["clearTask"]
for (const cmd of GLOBAL_COMMANDS) {
expect(validActions).toContain(cmd.action)
}
})
it("should match GlobalCommand interface", () => {
const testCommand: GlobalCommand = {
name: "test",
description: "Test command",
action: "clearTask",
}
expect(testCommand.name).toBe("test")
expect(testCommand.description).toBe("Test command")
expect(testCommand.action).toBe("clearTask")
})
})
})

View file

@ -0,0 +1,238 @@
import * as fs from "fs/promises"
import * as path from "path"
import {
getHistoryFilePath,
loadHistory,
saveHistory,
addToHistory,
MAX_HISTORY_ENTRIES,
} from "../utils/historyStorage.js"
vi.mock("fs/promises")
vi.mock("os", () => ({
homedir: vi.fn(() => "/home/testuser"),
}))
describe("historyStorage", () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe("getHistoryFilePath", () => {
it("should return the correct path to cli-history.json", () => {
const result = getHistoryFilePath()
expect(result).toBe(path.join("/home/testuser", ".roo", "cli-history.json"))
})
})
describe("loadHistory", () => {
it("should return empty array when file does not exist", async () => {
const error = new Error("ENOENT") as NodeJS.ErrnoException
error.code = "ENOENT"
vi.mocked(fs.readFile).mockRejectedValue(error)
const result = await loadHistory()
expect(result).toEqual([])
})
it("should return entries from valid JSON file", async () => {
const mockData = {
version: 1,
entries: ["first command", "second command", "third command"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await loadHistory()
expect(result).toEqual(["first command", "second command", "third command"])
})
it("should return empty array for invalid JSON", async () => {
vi.mocked(fs.readFile).mockResolvedValue("not valid json")
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const result = await loadHistory()
expect(result).toEqual([])
consoleSpy.mockRestore()
})
it("should filter out non-string entries", async () => {
const mockData = {
version: 1,
entries: ["valid", 123, "also valid", null, ""],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await loadHistory()
expect(result).toEqual(["valid", "also valid"])
})
it("should return empty array when entries is not an array", async () => {
const mockData = {
version: 1,
entries: "not an array",
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await loadHistory()
expect(result).toEqual([])
})
})
describe("saveHistory", () => {
it("should create directory and save history", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
await saveHistory(["command1", "command2"])
expect(fs.mkdir).toHaveBeenCalledWith(path.join("/home/testuser", ".roo"), { recursive: true })
expect(fs.writeFile).toHaveBeenCalled()
// Verify the content written
const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
const writtenContent = JSON.parse(writeCall?.[1] as string)
expect(writtenContent.version).toBe(1)
expect(writtenContent.entries).toEqual(["command1", "command2"])
})
it("should trim entries to MAX_HISTORY_ENTRIES", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
// Create array larger than MAX_HISTORY_ENTRIES
const manyEntries = Array.from({ length: MAX_HISTORY_ENTRIES + 100 }, (_, i) => `command${i}`)
await saveHistory(manyEntries)
const writeCall = vi.mocked(fs.writeFile).mock.calls[0]
const writtenContent = JSON.parse(writeCall?.[1] as string)
expect(writtenContent.entries.length).toBe(MAX_HISTORY_ENTRIES)
// Should keep the most recent entries (last 500)
expect(writtenContent.entries[0]).toBe(`command100`)
expect(writtenContent.entries[MAX_HISTORY_ENTRIES - 1]).toBe(`command${MAX_HISTORY_ENTRIES + 99}`)
})
it("should handle directory already exists error", async () => {
const error = new Error("EEXIST") as NodeJS.ErrnoException
error.code = "EEXIST"
vi.mocked(fs.mkdir).mockRejectedValue(error)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
// Should not throw
await expect(saveHistory(["command"])).resolves.not.toThrow()
})
it("should log warning on write error but not throw", async () => {
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Permission denied"))
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
await expect(saveHistory(["command"])).resolves.not.toThrow()
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Could not save CLI history"),
expect.any(String),
)
consoleSpy.mockRestore()
})
})
describe("addToHistory", () => {
it("should add new entry to history", async () => {
const mockData = {
version: 1,
entries: ["existing command"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await addToHistory("new command")
expect(result).toEqual(["existing command", "new command"])
})
it("should not add empty strings", async () => {
const mockData = {
version: 1,
entries: ["existing command"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await addToHistory("")
expect(result).toEqual(["existing command"])
expect(fs.writeFile).not.toHaveBeenCalled()
})
it("should not add whitespace-only strings", async () => {
const mockData = {
version: 1,
entries: ["existing command"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await addToHistory(" ")
expect(result).toEqual(["existing command"])
expect(fs.writeFile).not.toHaveBeenCalled()
})
it("should not add consecutive duplicates", async () => {
const mockData = {
version: 1,
entries: ["first", "second"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
const result = await addToHistory("second")
expect(result).toEqual(["first", "second"])
expect(fs.writeFile).not.toHaveBeenCalled()
})
it("should add non-consecutive duplicates", async () => {
const mockData = {
version: 1,
entries: ["first", "second"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await addToHistory("first")
expect(result).toEqual(["first", "second", "first"])
})
it("should trim whitespace from entry before adding", async () => {
const mockData = {
version: 1,
entries: ["existing"],
}
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockData))
vi.mocked(fs.mkdir).mockResolvedValue(undefined)
vi.mocked(fs.writeFile).mockResolvedValue(undefined)
const result = await addToHistory(" new command ")
expect(result).toEqual(["existing", "new command"])
})
})
describe("MAX_HISTORY_ENTRIES", () => {
it("should be 500", () => {
expect(MAX_HISTORY_ENTRIES).toBe(500)
})
})
})

View file

@ -0,0 +1,163 @@
import * as historyStorage from "../utils/historyStorage.js"
vi.mock("../utils/historyStorage.js")
// Track state and callbacks for testing.
let mockState: Record<string, unknown> = {}
let effectCallbacks: Array<() => void | (() => void)> = []
vi.mock("react", () => ({
useState: vi.fn((initial: unknown) => {
const key = `state_${Object.keys(mockState).length}`
if (!(key in mockState)) {
mockState[key] = initial
}
return [
mockState[key],
(newValue: unknown) => {
if (typeof newValue === "function") {
mockState[key] = (newValue as (prev: unknown) => unknown)(mockState[key])
} else {
mockState[key] = newValue
}
},
]
}),
useEffect: vi.fn((callback: () => void | (() => void)) => {
effectCallbacks.push(callback)
}),
useCallback: vi.fn((callback: unknown) => callback),
useRef: vi.fn((initial: unknown) => ({ current: initial })),
}))
describe("useInputHistory", () => {
beforeEach(() => {
vi.resetAllMocks()
mockState = {}
effectCallbacks = []
// Default mock for loadHistory
vi.mocked(historyStorage.loadHistory).mockResolvedValue([])
vi.mocked(historyStorage.addToHistory).mockImplementation(async (entry) => [entry])
})
describe("historyStorage functions", () => {
it("loadHistory should be called when hook effect runs", async () => {
vi.mocked(historyStorage.loadHistory).mockResolvedValue(["entry1", "entry2"])
// Import the hook (this triggers the module initialization)
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
useInputHistory()
// Run the effect callbacks
for (const cb of effectCallbacks) {
cb()
}
expect(historyStorage.loadHistory).toHaveBeenCalled()
})
it("addToHistory should be called with trimmed entry", async () => {
vi.mocked(historyStorage.addToHistory).mockResolvedValue(["new entry"])
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
await result.addEntry(" new entry ")
expect(historyStorage.addToHistory).toHaveBeenCalledWith("new entry")
})
it("addToHistory should not be called for empty entries", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
await result.addEntry("")
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
})
it("addToHistory should not be called for whitespace-only entries", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
await result.addEntry(" ")
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
})
})
describe("navigation logic", () => {
it("should have initial state with no history value", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
// Initial state should have null history value (not browsing)
expect(result.historyValue).toBeNull()
expect(result.isBrowsing).toBe(false)
})
it("should export navigateUp and navigateDown functions for manual navigation", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
expect(typeof result.navigateUp).toBe("function")
expect(typeof result.navigateDown).toBe("function")
})
})
describe("resetBrowsing", () => {
it("should be a function", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
expect(typeof result.resetBrowsing).toBe("function")
})
})
describe("return value structure", () => {
it("should return the expected interface", async () => {
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
const result = useInputHistory()
expect(result).toHaveProperty("addEntry")
expect(result).toHaveProperty("historyValue")
expect(result).toHaveProperty("isBrowsing")
expect(result).toHaveProperty("resetBrowsing")
expect(result).toHaveProperty("history")
expect(result).toHaveProperty("draft")
expect(result).toHaveProperty("navigateUp")
expect(result).toHaveProperty("navigateDown")
expect(typeof result.addEntry).toBe("function")
expect(typeof result.resetBrowsing).toBe("function")
expect(typeof result.navigateUp).toBe("function")
expect(typeof result.navigateDown).toBe("function")
expect(Array.isArray(result.history)).toBe(true)
})
})
})
describe("historyStorage integration", () => {
// Test the actual historyStorage functions directly
// These are more reliable than hook tests with mocked React
beforeEach(() => {
vi.resetAllMocks()
})
it("MAX_HISTORY_ENTRIES should be 500", async () => {
const { MAX_HISTORY_ENTRIES } = await import("../utils/historyStorage.js")
expect(MAX_HISTORY_ENTRIES).toBe(500)
})
it("getHistoryFilePath should return path in ~/.roo directory", async () => {
// Un-mock for this test
vi.doUnmock("../utils/historyStorage.js")
const { getHistoryFilePath } = await import("../utils/historyStorage.js")
const path = getHistoryFilePath()
expect(path).toContain(".roo")
expect(path).toContain("cli-history.json")
})
})

View file

@ -0,0 +1,5 @@
/**
* Default timeout in seconds for auto-approving followup questions.
* Used in both the TUI (App.tsx) and the extension host (extension-host.ts).
*/
export const FOLLOWUP_TIMEOUT_SECONDS = 60

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
/**
* CLI-specific global slash commands
*
* These commands are handled entirely within the CLI and trigger actions
* by sending messages to the extension host. They are separate from the
* extension's built-in commands which expand into prompt content.
*/
/**
* Action types that can be triggered by global commands.
* Each action corresponds to a message type sent to the extension host.
*/
export type GlobalCommandAction = "clearTask"
/**
* Definition of a CLI global command
*/
export interface GlobalCommand {
/** Command name (without the leading /) */
name: string
/** Description shown in the autocomplete picker */
description: string
/** Action to trigger when the command is executed */
action: GlobalCommandAction
}
/**
* CLI-specific global slash commands
* These commands trigger actions rather than expanding into prompt content.
*/
export const GLOBAL_COMMANDS: GlobalCommand[] = [
{
name: "new",
description: "Start a new task",
action: "clearTask",
},
]
/**
* Get a global command by name
*/
export function getGlobalCommand(name: string): GlobalCommand | undefined {
return GLOBAL_COMMANDS.find((cmd) => cmd.name === name)
}
/**
* Get global commands formatted for autocomplete
* Returns commands in the SlashCommandResult format expected by the autocomplete trigger
*/
export function getGlobalCommandsForAutocomplete(): Array<{
name: string
description?: string
source: "global" | "project" | "built-in"
action?: string
}> {
return GLOBAL_COMMANDS.map((cmd) => ({
name: cmd.name,
description: cmd.description,
source: "global" as const,
action: cmd.action,
}))
}

View file

@ -4,8 +4,10 @@
import { Command } from "commander"
import fs from "fs"
import { createRequire } from "module"
import path from "path"
import { fileURLToPath } from "url"
import { createElement } from "react"
import {
type ProviderName,
@ -28,13 +30,20 @@ const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// Read version from package.json
const require = createRequire(import.meta.url)
const packageJson = require("../package.json")
const program = new Command()
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0")
program
.name("roo")
.description("Roo Code CLI - Run the Roo Code agent from the command line")
.version(packageJson.version)
program
.argument("<prompt>", "The prompt/task to execute")
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
.argument("[workspace]", "Workspace path to operate in", process.cwd())
.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
.option("-e, --extension <path>", "Path to the extension bundle directory")
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
@ -49,11 +58,13 @@ program
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULTS.reasoningEffort,
)
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
.option("--no-tui", "Disable TUI, use plain text output")
.action(
async (
prompt: string,
workspaceArg: string,
options: {
workspace: string
prompt?: string
extension?: string
verbose: boolean
debug: boolean
@ -64,6 +75,8 @@ program
model?: string
mode?: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
ephemeral: boolean
tui: boolean
},
) => {
// Default is quiet mode - suppress VSCode shim logs unless verbose
@ -79,7 +92,7 @@ program
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(options.workspace)
const workspacePath = path.resolve(workspaceArg)
if (!apiKey) {
console.error(
@ -106,57 +119,147 @@ program
process.exit(1)
}
console.log(`[CLI] Mode: ${options.mode || "default"}`)
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
console.log(`[CLI] Provider: ${options.provider}`)
console.log(`[CLI] Model: ${options.model || "default"}`)
console.log(`[CLI] Workspace: ${workspacePath}`)
// TUI is enabled by default, disabled with --no-tui
// TUI requires raw mode support (proper TTY for stdin and stdout)
const canUseTui = process.stdin.isTTY && process.stdout.isTTY
const useTui = options.tui && canUseTui
const host = new ExtensionHost({
mode: options.mode || DEFAULTS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
apiProvider: options.provider,
apiKey,
model: options.model || DEFAULTS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
verbose: options.debug,
quiet: !options.verbose && !options.debug,
nonInteractive: options.yes,
})
if (options.tui && !canUseTui) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
// Handle SIGINT (Ctrl+C)
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// Handle SIGTERM
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(prompt)
await host.dispose()
if (options.exitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
// In plain text mode, prompt is required
if (!useTui && !options.prompt) {
console.error("[CLI] Error: prompt is required in plain text mode")
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
process.exit(1)
}
if (useTui) {
// TUI Mode - render Ink application
try {
const { render } = await import("ink")
const { App } = await import("./ui/App.js")
// Create extension host factory for dependency injection
const createExtensionHost = (opts: {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: boolean
ephemeral?: boolean
}) => {
return new ExtensionHost({
mode: opts.mode,
reasoningEffort:
opts.reasoningEffort === "unspecified"
? undefined
: (opts.reasoningEffort as ReasoningEffortExtended | "disabled" | undefined),
apiProvider: opts.apiProvider as ProviderName,
apiKey: opts.apiKey,
model: opts.model,
workspacePath: opts.workspacePath,
extensionPath: opts.extensionPath,
verbose: opts.verbose,
quiet: opts.quiet,
nonInteractive: opts.nonInteractive,
disableOutput: opts.disableOutput,
ephemeral: opts.ephemeral,
})
}
render(
createElement(App, {
initialPrompt: options.prompt || "", // Empty string if no prompt - user will type in TUI
workspacePath: workspacePath,
extensionPath: path.resolve(extensionPath),
apiProvider: options.provider,
apiKey: apiKey,
model: options.model || DEFAULTS.model,
mode: options.mode || DEFAULTS.mode,
nonInteractive: options.yes,
verbose: options.verbose,
debug: options.debug,
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
ephemeral: options.ephemeral,
createExtensionHost: createExtensionHost,
version: packageJson.version,
}),
{
exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit
},
)
} catch (error) {
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
process.exit(1)
}
} else {
// Plain text mode (existing behavior)
console.log(`[CLI] Mode: ${options.mode || "default"}`)
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
console.log(`[CLI] Provider: ${options.provider}`)
console.log(`[CLI] Model: ${options.model || "default"}`)
console.log(`[CLI] Workspace: ${workspacePath}`)
const host = new ExtensionHost({
mode: options.mode || DEFAULTS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
apiProvider: options.provider,
apiKey,
model: options.model || DEFAULTS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
verbose: options.debug,
quiet: !options.verbose && !options.debug,
nonInteractive: options.yes,
ephemeral: options.ephemeral,
})
// Handle SIGINT (Ctrl+C)
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
// Handle SIGTERM
process.on("SIGTERM", async () => {
console.log("\n[CLI] Received SIGTERM, shutting down...")
await host.dispose()
process.exit(143)
})
try {
await host.activate()
await host.runTask(options.prompt!) // prompt is guaranteed non-null in plain text mode
await host.dispose()
if (options.exitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (options.debug && error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
}
},
)

1521
apps/cli/src/ui/App.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
import { memo } from "react"
import { Box, Newline, Text } from "ink"
import * as theme from "../utils/theme.js"
import type { TUIMessage } from "../types.js"
import TodoDisplay from "./TodoDisplay.js"
/**
* 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, "")
}
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 (
<Box flexDirection="column">
<TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
<Text>
<Newline />
</Text>
</Box>
)
}
// Sanitize toolDisplayOutput if present, otherwise use already-sanitized content
const toolContent = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : content
return (
<Box flexDirection="column" paddingX={1}>
<Text bold color={theme.toolHeader}>
{`tool - ${message.toolDisplayName || message.toolName || "unknown"}`}
</Text>
<Text color={theme.toolText}>
{toolContent}
<Newline />
</Text>
</Box>
)
}
case "system":
// System messages are typically rendered as Header, not here.
// But if they appear, show them subtly.
return (
<Box flexDirection="column" paddingX={1}>
<Text color="gray" dimColor>
{content}
<Newline />
</Text>
</Box>
)
default:
return null
}
}
export default memo(ChatHistoryItem)

View file

@ -0,0 +1,68 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
import * as theme from "../utils/theme.js"
import MetricsDisplay from "./MetricsDisplay.js"
interface HeaderProps {
cwd: string
model: string
mode: string
reasoningEffort?: string
version: string
tokenUsage?: TokenUsage | null
contextWindow?: number
}
const ASCII_ROO = ` _,' ___
<__\\__/ \\
\\_ / _\\
\\,\\ / \\\\
// \\\\
,/' \`\\_,`
function Header({ model, cwd, mode, reasoningEffort, version, tokenUsage, contextWindow }: HeaderProps) {
const { columns } = useTerminalSize()
const homeDir = process.env.HOME || process.env.USERPROFILE || ""
const displayCwd = cwd.startsWith(homeDir) ? cwd.replace(homeDir, "~") : cwd
const title = `Roo Code CLI v${version}`
const titlePart = `── ${title} `
const remainingDashes = Math.max(0, columns - titlePart.length)
// Only show metrics when we have token usage data
const showMetrics = tokenUsage && contextWindow && contextWindow > 0
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}>
<Text color={theme.dimText}>Workspace: {displayCwd}</Text>
<Text color={theme.dimText}>Mode: {mode}</Text>
<Text color={theme.dimText}>Model: {model}</Text>
<Text color={theme.dimText}>Reasoning: {reasoningEffort}</Text>
{showMetrics && (
<Box marginTop={1}>
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
</Box>
)}
</Box>
</Box>
</Box>
{/* Inline horizontal line using the same columns value */}
<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
</Box>
)
}
export default memo(Header)

View file

@ -0,0 +1,144 @@
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" | "check" | "cross" | "arrow-right" | "bullet" | "spinner"
/**
* 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: "\udb80\ude4b", fallback: "▼" },
file: { nerd: "\udb80\ude14", fallback: "●" },
check: { nerd: "\uf00c", fallback: "✓" },
cross: { nerd: "\uf00d", fallback: "✗" },
"arrow-right": { nerd: "\uf061", fallback: "→" },
bullet: { nerd: "\uf111", fallback: "•" },
spinner: { nerd: "\uf110", fallback: "*" },
}
/**
* 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
// DEBUG: Log icon selection
console.error(
`DEBUG Icon: name=${name}, shouldUseNerdFont=${shouldUseNerdFont}, envOverride=${process.env.ROOCODE_NERD_FONT}, icon.length=${icon.length}`,
)
// Use fixed-width Box to isolate surrogate pair width calculation
// from surrounding text. This prevents the off-by-one truncation bug.
const needsWidthFix = containsSurrogatePair(icon)
if (needsWidthFix) {
return (
<Box width={width}>
<Text color={color} {...textProps}>
{icon}
</Text>
</Box>
)
}
// For BMP characters (no surrogate pairs), render directly
return (
<Text color={color} {...textProps}>
{icon}
</Text>
)
}
/**
* Get the raw icon character (useful for string concatenation).
*/
export function getIconChar(name: IconName, useNerdFont?: boolean): string {
const iconDef = ICONS[name]
if (!iconDef) return ""
const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
}

View file

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

View file

@ -0,0 +1,69 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { TokenUsage } from "@roo-code/types"
import * as theme from "../utils/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 Context: [] 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>
<Text color={theme.dimText}>Context: </Text>
<ProgressBar value={contextTokens} max={contextWindow} width={12} />
</Box>
)
}
export default memo(MetricsDisplay)
export { formatNumber, formatCost }

View file

@ -0,0 +1,490 @@
/**
* 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"
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
}
// Ctrl+C: ignore (handled elsewhere)
if (key.ctrl && input === "c") {
return
}
// Option+Enter (macOS) / Alt+Enter / Shift+Enter: add new line
// When Option/Alt is held, the terminal sends \r but key.return is false.
// This allows us to distinguish it from a regular Enter.
// Also support various terminal encodings for Shift+Enter.
const isModifiedEnter =
(input === "\r" && !key.return) || // Option+Enter on macOS sends \r but key.return=false
(key.return && key.shift) || // Shift+Enter if terminal reports modifiers
input === "\x1b[13;2u" || // CSI u encoding for Shift+Enter
input === "\x1b[27;2;13~" || // xterm modifyOtherKeys encoding for Shift+Enter
input === "\x1b\r" || // Some terminals send ESC+CR for Shift+Enter
input === "\x1bOM" || // Some terminals
(input.startsWith("\x1b[") && input.includes(";2") && input.endsWith("u")) // General CSI u with shift modifier
if (isModifiedEnter) {
const newValue =
currentValue.slice(0, currentCursorIndex) + "\n" + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
return
}
// Enter: submit
if (key.return) {
onSubmit?.(currentValue)
return
}
// Tab: ignore for now
if (key.tab) {
return
}
// Arrow up: move cursor up one line, or trigger history if on first line
if (key.upArrow) {
if (!showCursor) return
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
if (line > 0) {
// Move to previous line
const targetLine = lines[line - 1]!
const newCol = Math.min(col, targetLine.length)
const newCursorIndex = getIndexFromPosition(currentValue, line - 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On first line - trigger history navigation callback
onUpAtFirstLine?.()
}
return
}
// Arrow down: move cursor down one line, or trigger history if on last line
if (key.downArrow) {
if (!showCursor) return
const lines = currentValue.split("\n")
const { line, col } = getCursorPosition(currentValue, currentCursorIndex)
if (line < lines.length - 1) {
// Move to next line
const targetLine = lines[line + 1]!
const newCol = Math.min(col, targetLine.length)
const newCursorIndex = getIndexFromPosition(currentValue, line + 1, newCol)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
} else {
// On last line - trigger history navigation callback
onDownAtLastLine?.()
}
return
}
// Arrow left: move cursor left
if (key.leftArrow) {
if (!showCursor) return
const newCursorIndex = Math.max(0, currentCursorIndex - 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Arrow right: move cursor right
if (key.rightArrow) {
if (!showCursor) return
const newCursorIndex = Math.min(currentValue.length, currentCursorIndex + 1)
cursorIndexRef.current = newCursorIndex
setCursorIndex(newCursorIndex)
return
}
// Backspace/Delete
if (key.backspace || key.delete) {
if (currentCursorIndex > 0) {
const newValue =
currentValue.slice(0, currentCursorIndex - 1) + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex - 1
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
}
return
}
// Normal character input
if (input) {
const normalized = normalizeLineEndings(input)
const newValue =
currentValue.slice(0, currentCursorIndex) + normalized + currentValue.slice(currentCursorIndex)
const newCursorIndex = currentCursorIndex + normalized.length
// Update refs immediately for next keystroke
valueRef.current = newValue
cursorIndexRef.current = newCursorIndex
onChange(newValue)
setCursorIndex(newCursorIndex)
}
},
{ isActive },
)
// Split value into lines for rendering
const lines = useMemo(() => {
if (!value && !isActive) {
return [placeholder]
}
if (!value) {
return [""]
}
return value.split("\n")
}, [value, placeholder, isActive])
// Determine which line and column the cursor is on
const cursorPosition = useMemo(() => {
if (!showCursor || !isActive) return null
return getCursorPosition(value, cursorIndex)
}, [value, cursorIndex, showCursor, isActive])
// Calculate visual rows with wrapping
const visualRows = useMemo(() => {
const rows: VisualRow[] = []
const promptLen = prompt.length
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]!
// All rows use the same prefix width (prompt length) for consistent alignment
const prefixLen = promptLen
// Calculate available width for text (terminal width minus prefix)
// Use a large number if columns is not provided
const availableWidth = columns ? Math.max(1, columns - prefixLen) : 10000
const lineRows = wrapLine(lineText, i, availableWidth)
rows.push(...lineRows)
}
return rows
}, [lines, columns, prompt.length])
// Render a visual row with optional cursor
// Uses a two-column flex layout to ensure all text is vertically aligned:
// - Column 1: Fixed width for the prompt (only shown on first row)
// - Column 2: Text content
const renderVisualRow = useCallback(
(row: VisualRow, rowIndex: number) => {
const isPlaceholder = !value && !isActive && row.logicalLineIndex === 0
const promptWidth = prompt.length
// Only show the prompt on the very first visual row (first row of first line)
const showPrompt = row.logicalLineIndex === 0 && row.isFirstRowOfLine
// Check if cursor is on this visual row
let hasCursor = false
let cursorColInRow = -1
if (cursorPosition && cursorPosition.line === row.logicalLineIndex && isActive) {
const cursorCol = cursorPosition.col
// Check if cursor falls within this visual row's range
if (cursorCol >= row.startCol && cursorCol < row.startCol + row.text.length) {
hasCursor = true
cursorColInRow = cursorCol - row.startCol
}
// Cursor at the end of this row (for the last row of a line)
else if (cursorCol === row.startCol + row.text.length) {
// Check if this is the last visual row for this logical line
const nextRow = visualRows[rowIndex + 1]
if (!nextRow || nextRow.logicalLineIndex !== row.logicalLineIndex) {
hasCursor = true
cursorColInRow = row.text.length
}
}
}
if (hasCursor) {
const beforeCursor = row.text.slice(0, cursorColInRow)
const cursorAtEnd = cursorColInRow >= row.text.length
const cursorChar = cursorAtEnd ? " " : row.text[cursorColInRow]!
const afterCursor = cursorAtEnd ? "" : row.text.slice(cursorColInRow + 1)
// Check if adding cursor space at end would overflow the line width.
// When cursor is at the end of a max-width row, rendering an extra space
// would push the content beyond the terminal width, causing visual shift.
const wouldOverflow =
columns !== undefined && cursorAtEnd && promptWidth + row.text.length + 1 > columns
if (wouldOverflow) {
// Don't add extra space - cursor will appear at start of next row when text wraps
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text>{row.text}</Text>
</Box>
)
}
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text>{beforeCursor}</Text>
<Text inverse>{cursorChar}</Text>
<Text>{afterCursor}</Text>
</Box>
)
}
// For rows without cursor, use a space for empty text to ensure the row has height
// This fixes the issue where empty newlines don't expand the component height
const displayText = row.text.length === 0 ? " " : row.text
return (
<Box key={rowIndex} flexDirection="row">
<Box width={promptWidth}>{showPrompt && <Text>{prompt}</Text>}</Box>
<Text dimColor={isPlaceholder}>{displayText}</Text>
</Box>
)
},
[prompt, cursorPosition, value, isActive, visualRows, columns],
)
return <Box flexDirection="column">{visualRows.map((row, index) => renderVisualRow(row, index))}</Box>
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,179 @@
import { memo } from "react"
import { Box, Text } from "ink"
import type { TodoItem } from "@roo-code/types"
import * as theme from "../utils/theme.js"
import ProgressBar from "./ProgressBar.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 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: "TODO List Updated") */
title?: string
}
/**
* TodoDisplay component for CLI
*
* Renders a beautiful TODO list visualization with:
* - Status icons ( completed, in progress, pending)
* - Color-coded items based on status
* - Progress bar showing completion percentage
* - Optional diff mode showing only changed items
*
* Visual example:
* ```
* TODO List Updated
* Analyze requirements
* Design architecture
* Implement core logic
* Write tests
* Update documentation
* [] 2/5 completed
*
* ```
*/
function TodoDisplay({
todos,
previousTodos = [],
showProgress = true,
showChangesOnly = false,
title = "TODO List Updated",
}: 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
const inProgressCount = todos.filter((t) => t.status === "in_progress").length
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Text color={theme.toolHeader} bold>
{title}
</Text>
</Box>
{/* Border top */}
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
</Box>
{/* TODO items */}
<Box flexDirection="column" paddingLeft={1}>
{displayTodos.map((todo, index) => {
const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.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}`}>
<Text color={color}>
{icon} {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>
{/* Progress bar and stats */}
{showProgress && (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
</Box>
<Box paddingLeft={1}>
<ProgressBar value={completedCount} max={totalCount} width={16} />
<Text color={theme.dimText}>
{" "}
{completedCount}/{totalCount} completed
{inProgressCount > 0 && `, ${inProgressCount} in progress`}
</Text>
</Box>
</Box>
)}
</Box>
)
}
export default memo(TodoDisplay)

View file

@ -0,0 +1,234 @@
import { render } from "ink-testing-library"
import type { TUIMessage } from "../../types.js"
import ChatHistoryItem from "../ChatHistoryItem.js"
describe("ChatHistoryItem", () => {
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", () => {
const message: TUIMessage = {
id: "4",
role: "tool",
content: '{\n\t"key": "value"\n}',
toolName: "read_file",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain(' "key": "value"')
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 tool name", () => {
const message: TUIMessage = {
id: "4",
role: "tool",
content: "Output",
toolName: "read_file",
toolDisplayName: "Read File",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("tool - Read File")
expect(output).toContain("Output")
})
it("uses fallback content when message.content is empty", () => {
const message: TUIMessage = {
id: "5",
role: "assistant",
content: "",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
const output = lastFrame()
expect(output).toContain("...")
})
it("returns null for unknown role", () => {
const message = {
id: "6",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
role: "unknown" as any,
content: "Test",
}
const { lastFrame } = render(<ChatHistoryItem message={message} />)
expect(lastFrame()).toBe("")
})
})
})

View file

@ -0,0 +1,162 @@
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 surrogate pairs (length 2)
expect(getIconChar("folder").length).toBe(2)
expect(getIconChar("file").length).toBe(2)
})
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(2)
})
it("should return empty string for unknown icon", () => {
// @ts-expect-error - testing invalid icon name
expect(getIconChar("unknown")).toBe("")
})
})
})

View file

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

View file

@ -0,0 +1,138 @@
import { render } from "ink-testing-library"
import type { TodoItem } from "@roo-code/types"
import TodoDisplay from "../TodoDisplay.js"
describe("TodoDisplay", () => {
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
expect(output).toContain("TODO List Updated")
// 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
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 stats
expect(output).toContain("2/5 completed")
})
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()
expect(output).toContain("1/4 completed")
expect(output).toContain("2 in progress")
})
})

View file

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

View file

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

View file

@ -0,0 +1,62 @@
/**
* 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 { AutocompleteInput, type AutocompleteInputProps, type AutocompleteInputHandle } from "./AutocompleteInput.js"
export { PickerSelect, type PickerSelectProps } from "./PickerSelect.js"
// Hook
export { useAutocompletePicker } from "./useAutocompletePicker.js"
// Types
export type {
AutocompleteItem,
AutocompleteTrigger,
AutocompletePickerState,
AutocompletePickerActions,
TriggerDetectionResult,
} from "./types.js"
// Triggers
export {
createFileTrigger,
toFileResult,
type FileResult,
type FileTriggerConfig,
createSlashCommandTrigger,
toSlashCommandResult,
type SlashCommandResult,
type SlashCommandTriggerConfig,
createModeTrigger,
toModeResult,
type ModeResult,
type ModeTriggerConfig,
createHelpTrigger,
type HelpShortcutResult,
} from "./triggers/index.js"

View file

@ -0,0 +1,178 @@
import { render } from "ink-testing-library"
import { describe, it, expect } from "vitest"
import { createFileTrigger, toFileResult } from "./FileTrigger.js"
describe("FileTrigger", () => {
describe("createFileTrigger", () => {
it("should detect @ trigger", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const result = trigger.detectTrigger("@fil")
expect(result).toEqual({ query: "fil", triggerIndex: 0 })
})
it("should detect @ trigger in middle of line", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const result = trigger.detectTrigger("some text @fil")
expect(result).toEqual({ query: "fil", triggerIndex: 10 })
})
it("should detect @ even without text after it", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const result = trigger.detectTrigger("@")
expect(result).toEqual({ query: "", triggerIndex: 0 })
})
it("should not detect @ followed by space", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const result = trigger.detectTrigger("@ ")
expect(result).toBeNull()
})
it("should close picker when query contains space", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const result = trigger.detectTrigger("@file name")
expect(result).toBeNull()
})
it("should generate correct replacement text for files", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
const item = toFileResult({ path: "src/index.ts", type: "file" })
const lineText = "Check @ind"
const replacement = trigger.getReplacementText(item, lineText, 6)
expect(replacement).toBe("Check @/src/index.ts ")
})
it("should generate correct replacement text for folders", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
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 trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
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)
})
it("should render file items correctly", () => {
const trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
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 trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
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 trigger = createFileTrigger({
onSearch: () => {},
getResults: () => [],
})
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]/)
})
})
describe("toFileResult", () => {
it("should convert file search result to FileResult", () => {
const result = toFileResult({ path: "src/index.ts", type: "file" })
expect(result).toEqual({
key: "src/index.ts",
path: "src/index.ts",
type: "file",
})
})
it("should preserve label", () => {
const result = toFileResult({
path: "src/index.ts",
type: "file",
label: "Main entry",
})
expect(result).toEqual({
key: "src/index.ts",
path: "src/index.ts",
type: "file",
label: "Main entry",
})
})
})
})

View file

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

View file

@ -0,0 +1,146 @@
import { render } from "ink-testing-library"
import { describe, it, expect } from "vitest"
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(6)
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 + c")
})
it("should filter shortcuts by shortcut character", () => {
const trigger = createHelpTrigger()
const results = trigger.search("/") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("/")
})
it("should filter shortcuts by description", () => {
const trigger = createHelpTrigger()
const results = trigger.search("file") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("@")
expect(results[0]?.description).toContain("file")
})
it("should filter case-insensitively", () => {
const trigger = createHelpTrigger()
const results = trigger.search("QUIT") as HelpShortcutResult[]
expect(results.length).toBe(1)
expect(results[0]?.shortcut).toBe("ctrl + c")
})
it("should return empty array for non-matching query", () => {
const trigger = createHelpTrigger()
const results = trigger.search("xyz") as HelpShortcutResult[]
expect(results.length).toBe(0)
})
it("should generate replacement text for trigger shortcuts", () => {
const trigger = createHelpTrigger()
const slashItem: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const replacement = trigger.getReplacementText(slashItem, "?", 0)
expect(replacement).toBe("/")
})
it("should clear input for action shortcuts", () => {
const trigger = createHelpTrigger()
const tabItem: HelpShortcutResult = { key: "focus", shortcut: "tab", description: "to toggle focus" }
const replacement = trigger.getReplacementText(tabItem, "?tab", 0)
expect(replacement).toBe("")
})
it("should render shortcut items correctly", () => {
const trigger = createHelpTrigger()
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const output = lastFrame()
expect(output).toContain("/")
expect(output).toContain("for commands")
})
it("should render selected items with different styling", () => {
const trigger = createHelpTrigger()
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement)
// Both should contain the content
expect(unselectedFrame()).toContain("/")
expect(selectedFrame()).toContain("/")
})
it("should have correct trigger configuration", () => {
const trigger = createHelpTrigger()
expect(trigger.id).toBe("help")
expect(trigger.triggerChar).toBe("?")
expect(trigger.position).toBe("line-start")
expect(trigger.emptyMessage).toBe("No matching shortcuts")
expect(trigger.debounceMs).toBe(0)
})
it("should have consumeTrigger set to true", () => {
const trigger = createHelpTrigger()
// The ? character should be consumed (not inserted into input)
// when the help menu is triggered
expect(trigger.consumeTrigger).toBe(true)
})
})
})

View file

@ -0,0 +1,106 @@
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: "newline", shortcut: "shift + ⏎", description: "for newline" },
{ key: "focus", shortcut: "tab", description: "to toggle focus" },
{ 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), just clear the input
if (["newline", "focus", "quit"].includes(item.key)) {
return ""
}
// For trigger shortcuts (/, @, !), insert the trigger character
return item.shortcut
},
emptyMessage: "No matching shortcuts",
debounceMs: 0, // No debounce needed for static list
}
}

View file

@ -0,0 +1,128 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* Mode result type.
* Extends AutocompleteItem with mode-specific properties.
*/
export interface ModeResult extends AutocompleteItem {
/** Mode slug (e.g., "code", "architect") */
slug: string
/** Mode display name */
name: string
/** Optional description of the mode */
description?: string
/** Optional icon for the mode */
icon?: string
}
/**
* Props for creating a mode trigger
*/
export interface ModeTriggerConfig {
/**
* Get all available modes for filtering.
* Modes are filtered locally using fuzzy search.
*/
getModes: () => ModeResult[]
/**
* Maximum number of results to show.
* @default 20
*/
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 ModeResult.
* Use this to adapt modes from the store to the trigger's expected type.
*/
export function toModeResult(mode: { slug: string; name: string; description?: string; icon?: string }): ModeResult {
return {
key: mode.slug,
slug: mode.slug,
name: mode.name,
description: mode.description,
icon: mode.icon,
}
}

View file

@ -0,0 +1,144 @@
import { Box, Text } from "ink"
import fuzzysort from "fuzzysort"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* Slash command result type.
* Extends AutocompleteItem with command-specific properties.
*/
export interface SlashCommandResult extends AutocompleteItem {
/** Command name (without the leading /) */
name: string
/** Optional description of what the command does */
description?: string
/** Optional hint about command arguments */
argumentHint?: string
/** Source of the command */
source: "global" | "project" | "built-in"
/** Action to trigger for CLI global commands (only present for action commands) */
action?: string
}
/**
* Props for creating a slash command trigger
*/
export interface SlashCommandTriggerConfig {
/**
* Get all available commands for filtering.
* Commands are filtered locally using fuzzy search.
*/
getCommands: () => SlashCommandResult[]
/**
* Maximum number of results to show.
* @default 20
*/
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"
}): SlashCommandResult {
return {
key: command.name,
name: command.name,
description: command.description,
argumentHint: command.argumentHint,
source: command.source,
}
}

View file

@ -0,0 +1,16 @@
/**
* Autocomplete triggers for different trigger patterns.
*/
export { createFileTrigger, toFileResult, type FileResult, type FileTriggerConfig } from "./FileTrigger.js"
export {
createSlashCommandTrigger,
toSlashCommandResult,
type SlashCommandResult,
type SlashCommandTriggerConfig,
} from "./SlashCommandTrigger.js"
export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js"
export { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.js"

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,59 @@
/**
* 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
}

39
apps/cli/src/ui/index.ts Normal file
View file

@ -0,0 +1,39 @@
// Main App
export { type TUIAppProps, App } from "./App.js"
// Components
export { default as Header } from "./components/Header.js"
export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js"
export { default as LoadingText } from "./components/LoadingText.js"
// Autocomplete system
export {
AutocompleteInput,
PickerSelect,
useAutocompletePicker,
createFileTrigger,
createSlashCommandTrigger,
toFileResult,
toSlashCommandResult,
type AutocompleteInputProps,
type AutocompleteInputHandle,
type AutocompleteItem,
type AutocompleteTrigger,
type AutocompletePickerState,
type PickerSelectProps,
type FileResult,
type SlashCommandResult as AutocompleteSlashCommandResult,
} from "./components/autocomplete/index.js"
// Hooks
export { useInputHistory } from "./hooks/useInputHistory.js"
export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useInputHistory.js"
// Store
export { useCLIStore } from "./store.js"
// Theme
export * as theme from "./utils/theme.js"
// Types
export type { TUIMessage, PendingAsk, SayType, AskType, AppProps, MessageRole, View } from "./types.js"

161
apps/cli/src/ui/store.ts Normal file
View file

@ -0,0 +1,161 @@
import { create } from "zustand"
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult, ModeResult } from "./types.js"
/**
* 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
// Autocomplete data (from API/extension)
fileSearchResults: FileSearchResult[]
allSlashCommands: SlashCommandResult[]
availableModes: ModeResult[]
// 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
// Autocomplete data actions
setFileSearchResults: (results: FileSearchResult[]) => void
setAllSlashCommands: (commands: SlashCommandResult[]) => void
setAvailableModes: (modes: ModeResult[]) => 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,
fileSearchResults: [],
allSlashCommands: [],
availableModes: [],
currentMode: null,
tokenUsage: null,
routerModels: null,
apiConfiguration: null,
currentTodos: [],
previousTodos: [],
}
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
...initialState,
addMessage: (msg) =>
set((state) => {
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
if (existingIndex !== -1) {
// Update existing message in place.
const updated = [...state.messages]
updated[existingIndex] = msg
return { messages: updated }
}
// Add new message.
return { messages: [...state.messages, msg] }
}),
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),
setFileSearchResults: (results) => set({ fileSearchResults: results }),
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
setAvailableModes: (modes) => set({ availableModes: modes }),
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,
})),
}))

102
apps/cli/src/ui/types.ts Normal file
View file

@ -0,0 +1,102 @@
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
import type { GlobalCommandAction } from "../globalCommands.js"
// Re-export TodoItem for convenience
export type { TodoItem }
export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
export type AskType = Extract<
ClineAsk,
| "followup"
| "command"
| "command_output"
| "tool"
| "browser_action_launch"
| "use_mcp_server"
| "api_req_failed"
| "resume_task"
| "resume_completed_task"
| "completion_result"
>
export type SayType =
| Extract<
ClineSay,
| "text"
| "reasoning"
| "command_output"
| "completion_result"
| "error"
| "api_req_started"
| "user_feedback"
| "checkpoint_saved"
>
| "thinking"
| "tool"
export interface TUIMessage {
id: string
role: MessageRole
content: string
toolName?: string
toolDisplayName?: string
toolDisplayOutput?: string
hasPendingToolCalls?: boolean
partial?: boolean
originalType?: SayType | AskType
/** TODO items for update_todo_list tool messages */
todos?: TodoItem[]
/** Previous TODO items for diff display */
previousTodos?: TodoItem[]
}
export interface PendingAsk {
id: string
type: AskType
content: string
suggestions?: Array<{ answer: string; mode?: string | null }>
}
export interface AppProps {
initialPrompt: string
workspacePath: string
extensionPath: string
apiProvider: string
apiKey: string
model: string
mode: string
nonInteractive: boolean
verbose: boolean
debug: boolean
exitOnComplete: boolean
reasoningEffort?: string
/** Run in ephemeral mode - no state persists after this session */
ephemeral?: boolean
version: string
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
export interface FileSearchResult {
path: string
type: "file" | "folder"
label?: string
}
export interface SlashCommandResult {
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 ModeResult {
slug: string
name: string
description?: string
icon?: string
}

View file

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

View file

@ -0,0 +1,67 @@
import type { ProviderSettings } from "@roo-code/types"
import type { RouterModels } from "../ui/store.js"
const DEFAULT_CONTEXT_WINDOW = 200_000
/**
* Looks up the context window size for the current model from routerModels.
*
* @param routerModels - The router models data containing model info per provider
* @param apiConfiguration - The current API configuration with provider and model ID
* @returns The context window size, or DEFAULT_CONTEXT_WINDOW (200K) if not found
*/
export function getContextWindow(routerModels: RouterModels | null, apiConfiguration: ProviderSettings | null): number {
if (!routerModels || !apiConfiguration) {
return DEFAULT_CONTEXT_WINDOW
}
const provider = apiConfiguration.apiProvider
const modelId = getModelIdForProvider(apiConfiguration)
if (!provider || !modelId) {
return DEFAULT_CONTEXT_WINDOW
}
const providerModels = routerModels[provider]
const modelInfo = providerModels?.[modelId]
return modelInfo?.contextWindow ?? DEFAULT_CONTEXT_WINDOW
}
/**
* Gets the model ID from the API configuration based on the provider type.
*
* Different providers store their model ID in different fields of ProviderSettings.
*/
function getModelIdForProvider(config: ProviderSettings): string | undefined {
switch (config.apiProvider) {
case "openrouter":
return config.openRouterModelId
case "ollama":
return config.ollamaModelId
case "lmstudio":
return config.lmStudioModelId
case "openai":
return config.openAiModelId
case "requesty":
return config.requestyModelId
case "litellm":
return config.litellmModelId
case "deepinfra":
return config.deepInfraModelId
case "huggingface":
return config.huggingFaceModelId
case "unbound":
return config.unboundModelId
case "vercel-ai-gateway":
return config.vercelAiGatewayModelId
case "io-intelligence":
return config.ioIntelligenceModelId
default:
// For anthropic, bedrock, vertex, gemini, xai, groq, etc.
return config.apiModelId
}
}
export { DEFAULT_CONTEXT_WINDOW }

View file

@ -0,0 +1,131 @@
import * as fs from "fs/promises"
import * as path from "path"
import * as os from "os"
/** Maximum number of history entries to keep */
export const MAX_HISTORY_ENTRIES = 500
/** History file format version for future migrations */
const HISTORY_VERSION = 1
interface HistoryData {
version: number
entries: string[]
}
/**
* Get the path to the history file
*/
export function getHistoryFilePath(): string {
return path.join(os.homedir(), ".roo", "cli-history.json")
}
/**
* Get the path to the .roo directory
*/
function getRooDir(): string {
return path.join(os.homedir(), ".roo")
}
/**
* Ensure the .roo directory exists
*/
async function ensureRooDir(): Promise<void> {
const rooDir = getRooDir()
try {
await fs.mkdir(rooDir, { recursive: true })
} catch (err) {
// Directory may already exist, that's fine
const error = err as NodeJS.ErrnoException
if (error.code !== "EEXIST") {
throw err
}
}
}
/**
* Load history entries from file
* Returns empty array if file doesn't exist or is invalid
*/
export async function loadHistory(): Promise<string[]> {
const filePath = getHistoryFilePath()
try {
const content = await fs.readFile(filePath, "utf-8")
const data: HistoryData = JSON.parse(content)
// Validate structure
if (!data || typeof data !== "object") {
return []
}
if (!Array.isArray(data.entries)) {
return []
}
// Filter to only valid strings
return data.entries.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
} catch (err) {
const error = err as NodeJS.ErrnoException
// File doesn't exist - that's expected on first run
if (error.code === "ENOENT") {
return []
}
// JSON parse error or other issue - log and return empty
console.error("Warning: Could not load CLI history:", error.message)
return []
}
}
/**
* Save history entries to file
* Creates the .roo directory if needed
* Trims to MAX_HISTORY_ENTRIES
*/
export async function saveHistory(entries: string[]): Promise<void> {
const filePath = getHistoryFilePath()
// Trim to max entries (keep most recent)
const trimmedEntries = entries.slice(-MAX_HISTORY_ENTRIES)
const data: HistoryData = {
version: HISTORY_VERSION,
entries: trimmedEntries,
}
try {
await ensureRooDir()
await fs.writeFile(filePath, JSON.stringify(data, null, "\t"), "utf-8")
} catch (err) {
const error = err as NodeJS.ErrnoException
// Log but don't throw - history persistence is not critical
console.error("Warning: Could not save CLI history:", error.message)
}
}
/**
* Add a new entry to history and save
* Avoids adding consecutive duplicates or empty entries
* Returns the updated history array
*/
export async function addToHistory(entry: string): Promise<string[]> {
const trimmed = entry.trim()
// Don't add empty entries
if (!trimmed) {
return await loadHistory()
}
const history = await loadHistory()
// Don't add consecutive duplicates
if (history.length > 0 && history[history.length - 1] === trimmed) {
return history
}
const updated = [...history, trimmed]
await saveHistory(updated)
return updated.slice(-MAX_HISTORY_ENTRIES)
}

View file

@ -2,7 +2,9 @@
"extends": "@roo-code/config-typescript/base.json",
"compilerOptions": {
"types": ["vitest/globals"],
"outDir": "dist"
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src", "*.config.ts"],
"exclude": ["node_modules"]

View file

@ -12,7 +12,7 @@ export default defineConfig({
js: "#!/usr/bin/env node",
},
// Bundle workspace packages that export TypeScript
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
noExternal: ["@roo-code/core", "@roo-code/core/message-utils", "@roo-code/types", "@roo-code/vscode-shim"],
external: [
// Keep native modules external
"@anthropic-ai/sdk",
@ -20,5 +20,12 @@ 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"
},
})

View file

@ -6,6 +6,6 @@ export default defineConfig({
environment: "node",
watch: false,
testTimeout: 120_000, // 2m for integration tests.
include: ["src/**/*.test.ts"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
})

View file

@ -20,7 +20,6 @@
"@vscode/test-electron": "^2.4.0",
"glob": "^11.1.0",
"mocha": "^11.1.0",
"rimraf": "^6.0.1",
"typescript": "5.8.3"
"rimraf": "^6.0.1"
}
}

View file

@ -45,7 +45,7 @@
"rimraf": "^6.0.1",
"tsx": "^4.19.3",
"turbo": "^2.5.6",
"typescript": "^5.4.5"
"typescript": "5.8.3"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
@ -63,7 +63,9 @@
"brace-expansion": "^2.0.2",
"form-data": ">=4.0.4",
"bluebird": ">=3.7.2",
"glob": ">=11.1.0"
"glob": ">=11.1.0",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.5"
}
}
}

View file

@ -3,7 +3,11 @@
"description": "Platform agnostic core functionality for Roo Code.",
"version": "0.0.0",
"type": "module",
"exports": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./message-utils": "./src/message-utils/index.ts",
"./debug-log": "./src/debug-log/index.ts"
},
"scripts": {
"lint": "eslint src --ext=ts --max-warnings=0",
"check-types": "tsc --noEmit",

View file

@ -0,0 +1,91 @@
/**
* File-based debug logging utility
*
* This writes logs to ~/.roo/cli-debug.log, avoiding stdout/stderr
* which would break TUI applications. The log format is timestamped JSON.
*
* Usage:
* import { debugLog, DebugLogger } from "@roo-code/core/debug-log"
*
* // Simple logging
* debugLog("handleModeSwitch", { mode: newMode, configId })
*
* // Or create a named logger for a component
* const log = new DebugLogger("ClineProvider")
* log.info("handleModeSwitch", { mode: newMode })
*/
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log")
/**
* Simple file-based debug log function.
* Writes timestamped entries to ~/.roo/cli-debug.log
*/
export function debugLog(message: string, data?: unknown): void {
try {
const logDir = path.dirname(DEBUG_LOG_PATH)
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
const timestamp = new Date().toISOString()
const entry = data
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
: `[${timestamp}] ${message}\n`
fs.appendFileSync(DEBUG_LOG_PATH, entry)
} catch {
// NO-OP - don't let logging errors break functionality
}
}
/**
* Debug logger with component context.
* Prefixes all messages with the component name.
*/
export class DebugLogger {
private component: string
constructor(component: string) {
this.component = component
}
/**
* Log a debug message with optional data
*/
debug(message: string, data?: unknown): void {
debugLog(`[${this.component}] ${message}`, data)
}
/**
* Alias for debug
*/
info(message: string, data?: unknown): void {
this.debug(message, data)
}
/**
* Log a warning
*/
warn(message: string, data?: unknown): void {
debugLog(`[${this.component}] WARN: ${message}`, data)
}
/**
* Log an error
*/
error(message: string, data?: unknown): void {
debugLog(`[${this.component}] ERROR: ${message}`, data)
}
}
/**
* Pre-configured logger for provider/mode debugging
*/
export const providerDebugLog = new DebugLogger("ProviderSettings")

View file

@ -1 +1,2 @@
export * from "./custom-tools/index.js"
export * from "./message-utils/index.js"

View file

@ -0,0 +1,122 @@
// npx vitest run packages/core/src/message-utils/__tests__/consolidateApiRequests.spec.ts
import type { ClineMessage } from "@roo-code/types"
import { consolidateApiRequests } from "../consolidateApiRequests.js"
describe("consolidateApiRequests", () => {
// Helper function to create a basic api_req_started message
const createApiReqStarted = (ts: number, data: Record<string, unknown> = {}): ClineMessage => ({
ts,
type: "say",
say: "api_req_started",
text: JSON.stringify(data),
})
// Helper function to create a basic api_req_finished message
const createApiReqFinished = (ts: number, data: Record<string, unknown> = {}): ClineMessage => ({
ts,
type: "say",
say: "api_req_finished",
text: JSON.stringify(data),
})
// Helper function to create a regular text message
const createTextMessage = (ts: number, text: string): ClineMessage => ({
ts,
type: "say",
say: "text",
text,
})
it("should consolidate a matching pair of api_req_started and api_req_finished messages", () => {
const messages: ClineMessage[] = [
createApiReqStarted(1000, { request: "GET /api/data" }),
createApiReqFinished(1001, { cost: 0.005 }),
]
const result = consolidateApiRequests(messages)
expect(result.length).toBe(1)
expect(result[0]!.say).toBe("api_req_started")
const parsedText = JSON.parse(result[0]!.text || "{}")
expect(parsedText.request).toBe("GET /api/data")
expect(parsedText.cost).toBe(0.005)
})
it("should handle messages with no api_req pairs", () => {
const messages: ClineMessage[] = [createTextMessage(1000, "Hello"), createTextMessage(1001, "World")]
const result = consolidateApiRequests(messages)
expect(result).toEqual(messages)
})
it("should handle empty messages array", () => {
const result = consolidateApiRequests([])
expect(result).toEqual([])
})
it("should handle single message array", () => {
const messages: ClineMessage[] = [createTextMessage(1000, "Hello")]
const result = consolidateApiRequests(messages)
expect(result).toEqual(messages)
})
it("should preserve non-api messages in the result", () => {
const messages: ClineMessage[] = [
createTextMessage(1000, "Before"),
createApiReqStarted(1001, { request: "test" }),
createApiReqFinished(1002, { cost: 0.01 }),
createTextMessage(1003, "After"),
]
const result = consolidateApiRequests(messages)
expect(result.length).toBe(3)
expect(result[0]!.text).toBe("Before")
expect(result[1]!.say).toBe("api_req_started")
expect(result[2]!.text).toBe("After")
})
it("should handle multiple api_req pairs", () => {
const messages: ClineMessage[] = [
createApiReqStarted(1000, { request: "first" }),
createApiReqFinished(1001, { cost: 0.01 }),
createApiReqStarted(1002, { request: "second" }),
createApiReqFinished(1003, { cost: 0.02 }),
]
const result = consolidateApiRequests(messages)
expect(result.length).toBe(2)
expect(JSON.parse(result[0]!.text || "{}").request).toBe("first")
expect(JSON.parse(result[1]!.text || "{}").request).toBe("second")
})
it("should handle orphan api_req_started without finish", () => {
const messages: ClineMessage[] = [
createApiReqStarted(1000, { request: "orphan" }),
createTextMessage(1001, "Text"),
]
const result = consolidateApiRequests(messages)
expect(result.length).toBe(2)
expect(result[0]!.say).toBe("api_req_started")
expect(JSON.parse(result[0]!.text || "{}").request).toBe("orphan")
})
it("should handle invalid JSON in message text", () => {
const messages: ClineMessage[] = [
{ ts: 1000, type: "say", say: "api_req_started", text: "invalid json" },
createApiReqFinished(1001, { cost: 0.01 }),
]
const result = consolidateApiRequests(messages)
// Should still consolidate, merging what it can
expect(result.length).toBe(1)
})
})

View file

@ -0,0 +1,145 @@
// npx vitest run packages/core/src/message-utils/__tests__/consolidateCommands.spec.ts
import type { ClineMessage } from "@roo-code/types"
import { consolidateCommands, COMMAND_OUTPUT_STRING } from "../consolidateCommands.js"
describe("consolidateCommands", () => {
describe("command sequences", () => {
it("should consolidate command and command_output messages", () => {
const messages: ClineMessage[] = [
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
{ type: "ask", ask: "command_output", text: "file1.txt", ts: 1001 },
{ type: "ask", ask: "command_output", text: "file2.txt", ts: 1002 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(1)
expect(result[0]!.ask).toBe("command")
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}file1.txt\nfile2.txt`)
})
it("should handle multiple command sequences", () => {
const messages: ClineMessage[] = [
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
{ type: "ask", ask: "command_output", text: "output1", ts: 1001 },
{ type: "ask", ask: "command", text: "pwd", ts: 1002 },
{ type: "ask", ask: "command_output", text: "output2", ts: 1003 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(2)
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}output1`)
expect(result[1]!.text).toBe(`pwd\n${COMMAND_OUTPUT_STRING}output2`)
})
it("should handle command without output", () => {
const messages: ClineMessage[] = [
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
{ type: "say", say: "text", text: "some text", ts: 1001 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(2)
expect(result[0]!.ask).toBe("command")
expect(result[0]!.text).toBe("ls")
expect(result[1]!.say).toBe("text")
})
it("should handle duplicate outputs (ask and say with same text)", () => {
const messages: ClineMessage[] = [
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
{ type: "ask", ask: "command_output", text: "same output", ts: 1001 },
{ type: "say", say: "command_output", text: "same output", ts: 1002 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(1)
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}same output`)
})
})
describe("MCP server sequences", () => {
it("should consolidate use_mcp_server and mcp_server_response messages", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "use_mcp_server",
text: JSON.stringify({ server: "test", tool: "myTool" }),
ts: 1000,
},
{ type: "say", say: "mcp_server_response", text: "response data", ts: 1001 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(1)
expect(result[0]!.ask).toBe("use_mcp_server")
const parsed = JSON.parse(result[0]!.text || "{}")
expect(parsed.server).toBe("test")
expect(parsed.response).toBe("response data")
})
it("should handle MCP request without response", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "use_mcp_server",
text: JSON.stringify({ server: "test" }),
ts: 1000,
},
]
const result = consolidateCommands(messages)
expect(result.length).toBe(1)
expect(result[0]!.ask).toBe("use_mcp_server")
})
it("should handle multiple MCP responses", () => {
const messages: ClineMessage[] = [
{
type: "ask",
ask: "use_mcp_server",
text: JSON.stringify({ server: "test" }),
ts: 1000,
},
{ type: "say", say: "mcp_server_response", text: "response1", ts: 1001 },
{ type: "say", say: "mcp_server_response", text: "response2", ts: 1002 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(1)
const parsed = JSON.parse(result[0]!.text || "{}")
expect(parsed.response).toBe("response1\nresponse2")
})
})
describe("mixed messages", () => {
it("should preserve non-command, non-MCP messages", () => {
const messages: ClineMessage[] = [
{ type: "say", say: "text", text: "before", ts: 1000 },
{ type: "ask", ask: "command", text: "ls", ts: 1001 },
{ type: "ask", ask: "command_output", text: "output", ts: 1002 },
{ type: "say", say: "text", text: "after", ts: 1003 },
]
const result = consolidateCommands(messages)
expect(result.length).toBe(3)
expect(result[0]!.text).toBe("before")
expect(result[1]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}output`)
expect(result[2]!.text).toBe("after")
})
it("should handle empty array", () => {
const result = consolidateCommands([])
expect(result).toEqual([])
})
})
})

View file

@ -0,0 +1,246 @@
// npx vitest run packages/core/src/message-utils/__tests__/consolidateTokenUsage.spec.ts
import type { ClineMessage } from "@roo-code/types"
import { consolidateTokenUsage, hasTokenUsageChanged, hasToolUsageChanged } from "../consolidateTokenUsage.js"
describe("consolidateTokenUsage", () => {
// Helper function to create a basic api_req_started message
const createApiReqMessage = (
ts: number,
data: {
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
cost?: number
},
): ClineMessage => ({
ts,
type: "say",
say: "api_req_started",
text: JSON.stringify(data),
})
describe("basic token accumulation", () => {
it("should accumulate tokens from a single message", () => {
const messages: ClineMessage[] = [createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cost: 0.01 })]
const result = consolidateTokenUsage(messages)
expect(result.totalTokensIn).toBe(100)
expect(result.totalTokensOut).toBe(50)
expect(result.totalCost).toBe(0.01)
})
it("should accumulate tokens from multiple messages", () => {
const messages: ClineMessage[] = [
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cost: 0.01 }),
createApiReqMessage(1001, { tokensIn: 200, tokensOut: 100, cost: 0.02 }),
]
const result = consolidateTokenUsage(messages)
expect(result.totalTokensIn).toBe(300)
expect(result.totalTokensOut).toBe(150)
expect(result.totalCost).toBeCloseTo(0.03)
})
it("should handle cache writes and reads", () => {
const messages: ClineMessage[] = [
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cacheWrites: 500, cacheReads: 200 }),
]
const result = consolidateTokenUsage(messages)
expect(result.totalCacheWrites).toBe(500)
expect(result.totalCacheReads).toBe(200)
})
it("should handle empty messages array", () => {
const result = consolidateTokenUsage([])
expect(result.totalTokensIn).toBe(0)
expect(result.totalTokensOut).toBe(0)
expect(result.totalCost).toBe(0)
expect(result.contextTokens).toBe(0)
})
})
describe("context tokens calculation", () => {
it("should calculate context tokens from the last API request", () => {
const messages: ClineMessage[] = [
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50 }),
createApiReqMessage(1001, { tokensIn: 200, tokensOut: 100 }),
]
const result = consolidateTokenUsage(messages)
// Context tokens = tokensIn + tokensOut from last message
expect(result.contextTokens).toBe(300) // 200 + 100
})
it("should handle condense_context messages for context tokens", () => {
const messages: ClineMessage[] = [
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50 }),
{
ts: 1001,
type: "say",
say: "condense_context",
contextCondense: { newContextTokens: 5000, cost: 0.05 },
} as ClineMessage,
]
const result = consolidateTokenUsage(messages)
expect(result.contextTokens).toBe(5000)
expect(result.totalCost).toBeCloseTo(0.05)
})
})
describe("invalid data handling", () => {
it("should handle messages with invalid JSON", () => {
const messages: ClineMessage[] = [{ ts: 1000, type: "say", say: "api_req_started", text: "invalid json" }]
// Should not throw
const result = consolidateTokenUsage(messages)
expect(result.totalTokensIn).toBe(0)
})
it("should skip non-api_req_started messages", () => {
const messages: ClineMessage[] = [
{ ts: 1000, type: "say", say: "text", text: "hello" },
createApiReqMessage(1001, { tokensIn: 100, tokensOut: 50 }),
]
const result = consolidateTokenUsage(messages)
expect(result.totalTokensIn).toBe(100)
expect(result.totalTokensOut).toBe(50)
})
it("should handle missing token values", () => {
const messages: ClineMessage[] = [createApiReqMessage(1000, { cost: 0.01 })]
const result = consolidateTokenUsage(messages)
expect(result.totalTokensIn).toBe(0)
expect(result.totalTokensOut).toBe(0)
expect(result.totalCost).toBe(0.01)
})
})
})
describe("hasTokenUsageChanged", () => {
it("should return true when snapshot is undefined", () => {
const current = {
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 150,
}
expect(hasTokenUsageChanged(current, undefined)).toBe(true)
})
it("should return false when values are the same", () => {
const current = {
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 150,
}
const snapshot = { ...current }
expect(hasTokenUsageChanged(current, snapshot)).toBe(false)
})
it("should return true when totalTokensIn changes", () => {
const current = {
totalTokensIn: 200,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 150,
}
const snapshot = {
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 150,
}
expect(hasTokenUsageChanged(current, snapshot)).toBe(true)
})
it("should return true when totalCost changes", () => {
const current = {
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.02,
contextTokens: 150,
}
const snapshot = {
totalTokensIn: 100,
totalTokensOut: 50,
totalCost: 0.01,
contextTokens: 150,
}
expect(hasTokenUsageChanged(current, snapshot)).toBe(true)
})
})
describe("hasToolUsageChanged", () => {
it("should return true when snapshot is undefined", () => {
const current = {
read_file: { attempts: 1, failures: 0 },
}
expect(hasToolUsageChanged(current, undefined)).toBe(true)
})
it("should return false when values are the same", () => {
const current = {
read_file: { attempts: 1, failures: 0 },
}
const snapshot = {
read_file: { attempts: 1, failures: 0 },
}
expect(hasToolUsageChanged(current, snapshot)).toBe(false)
})
it("should return true when a tool is added", () => {
const current = {
read_file: { attempts: 1, failures: 0 },
write_to_file: { attempts: 1, failures: 0 },
}
const snapshot = {
read_file: { attempts: 1, failures: 0 },
}
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
})
it("should return true when attempts change", () => {
const current = {
read_file: { attempts: 2, failures: 0 },
}
const snapshot = {
read_file: { attempts: 1, failures: 0 },
}
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
})
it("should return true when failures change", () => {
const current = {
read_file: { attempts: 1, failures: 1 },
}
const snapshot = {
read_file: { attempts: 1, failures: 0 },
}
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
})
})

View file

@ -0,0 +1,90 @@
import type { ClineMessage } from "@roo-code/types"
/**
* Consolidates API request start and finish messages in an array of ClineMessages.
*
* This function looks for pairs of 'api_req_started' and 'api_req_finished' messages.
* When it finds a pair, it consolidates them into a single message.
* The JSON data in the text fields of both messages are merged.
*
* @param messages - An array of ClineMessage objects to process.
* @returns A new array of ClineMessage objects with API requests consolidated.
*
* @example
* const messages = [
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data"}', ts: 1000 },
* { type: "say", say: "api_req_finished", text: '{"cost":0.005}', ts: 1001 }
* ];
* const result = consolidateApiRequests(messages);
* // Result: [{ type: "say", say: "api_req_started", text: '{"request":"GET /api/data","cost":0.005}', ts: 1000 }]
*/
export function consolidateApiRequests(messages: ClineMessage[]): ClineMessage[] {
if (messages.length === 0) {
return []
}
if (messages.length === 1) {
return messages
}
let isMergeNecessary = false
for (const msg of messages) {
if (msg.type === "say" && (msg.say === "api_req_started" || msg.say === "api_req_finished")) {
isMergeNecessary = true
break
}
}
if (!isMergeNecessary) {
return messages
}
const result: ClineMessage[] = []
const startedIndices: number[] = []
for (const message of messages) {
if (message.type !== "say" || (message.say !== "api_req_started" && message.say !== "api_req_finished")) {
result.push(message)
continue
}
if (message.say === "api_req_started") {
// Add to result and track the index.
result.push(message)
startedIndices.push(result.length - 1)
continue
}
// Find the most recent api_req_started that hasn't been consolidated.
const startIndex = startedIndices.length > 0 ? startedIndices.pop() : undefined
if (startIndex !== undefined) {
const startMessage = result[startIndex]
if (!startMessage) continue
let startData = {}
let finishData = {}
try {
if (startMessage.text) {
startData = JSON.parse(startMessage.text)
}
} catch {
// Ignore JSON parse errors
}
try {
if (message.text) {
finishData = JSON.parse(message.text)
}
} catch {
// Ignore JSON parse errors
}
result[startIndex] = { ...startMessage, text: JSON.stringify({ ...startData, ...finishData }) }
}
}
return result
}

View file

@ -0,0 +1,160 @@
import type { ClineMessage } from "@roo-code/types"
import { safeJsonParse } from "./safeJsonParse.js"
export const COMMAND_OUTPUT_STRING = "Output:"
/**
* Consolidates sequences of command and command_output messages in an array of ClineMessages.
* Also consolidates sequences of use_mcp_server and mcp_server_response messages.
*
* This function processes an array of ClineMessages objects, looking for sequences
* where a 'command' message is followed by one or more 'command_output' messages,
* or where a 'use_mcp_server' message is followed by one or more 'mcp_server_response' messages.
* When such a sequence is found, it consolidates them into a single message, merging
* their text contents.
*
* @param messages - An array of ClineMessage objects to process.
* @returns A new array of ClineMessage objects with command and MCP sequences consolidated.
*
* @example
* const messages: ClineMessage[] = [
* { type: 'ask', ask: 'command', text: 'ls', ts: 1625097600000 },
* { type: 'ask', ask: 'command_output', text: 'file1.txt', ts: 1625097601000 },
* { type: 'ask', ask: 'command_output', text: 'file2.txt', ts: 1625097602000 }
* ];
* const result = consolidateCommands(messages);
* // Result: [{ type: 'ask', ask: 'command', text: 'ls\nfile1.txt\nfile2.txt', ts: 1625097600000 }]
*/
export function consolidateCommands(messages: ClineMessage[]): ClineMessage[] {
const consolidatedMessages = new Map<number, ClineMessage>()
const processedIndices = new Set<number>()
// Single pass through all messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (!msg) continue
// Handle MCP server requests
if (msg.type === "ask" && msg.ask === "use_mcp_server") {
// Look ahead for MCP responses
const responses: string[] = []
let j = i + 1
while (j < messages.length) {
const nextMsg = messages[j]
if (!nextMsg) {
j++
continue
}
if (nextMsg.say === "mcp_server_response") {
responses.push(nextMsg.text || "")
processedIndices.add(j)
j++
} else if (nextMsg.type === "ask" && nextMsg.ask === "use_mcp_server") {
// Stop if we encounter another MCP request
break
} else {
j++
}
}
if (responses.length > 0) {
// Parse the JSON from the message text
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsonObj = safeJsonParse<any>(msg.text || "{}", {})
// Add the response to the JSON object
jsonObj.response = responses.join("\n")
// Stringify the updated JSON object
const consolidatedText = JSON.stringify(jsonObj)
consolidatedMessages.set(msg.ts, { ...msg, text: consolidatedText })
} else {
// If there's no response, just keep the original message
consolidatedMessages.set(msg.ts, { ...msg })
}
}
// Handle command sequences
else if (msg.type === "ask" && msg.ask === "command") {
let consolidatedText = msg.text || ""
let j = i + 1
let previous: { type: "ask" | "say"; text: string } | undefined
let lastProcessedIndex = i
while (j < messages.length) {
const currentMsg = messages[j]
if (!currentMsg) {
j++
continue
}
const { type, ask, say, text = "" } = currentMsg
if (type === "ask" && ask === "command") {
break // Stop if we encounter the next command.
}
if (ask === "command_output" || say === "command_output") {
if (!previous) {
consolidatedText += `\n${COMMAND_OUTPUT_STRING}`
}
const isDuplicate = previous && previous.type !== type && previous.text === text
if (text.length > 0 && !isDuplicate) {
// Add a newline before adding the text if there's already content
if (
previous &&
consolidatedText.length >
consolidatedText.indexOf(COMMAND_OUTPUT_STRING) + COMMAND_OUTPUT_STRING.length
) {
consolidatedText += "\n"
}
consolidatedText += text
}
previous = { type, text }
processedIndices.add(j)
lastProcessedIndex = j
}
j++
}
consolidatedMessages.set(msg.ts, { ...msg, text: consolidatedText })
// Only skip ahead if we actually processed command outputs
if (lastProcessedIndex > i) {
i = lastProcessedIndex
}
}
}
// Build final result: filter out processed messages and use consolidated versions
const result: ClineMessage[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (!msg) continue
// Skip messages that were processed as outputs/responses
if (processedIndices.has(i)) {
continue
}
// Skip command_output and mcp_server_response messages
if (msg.ask === "command_output" || msg.say === "command_output" || msg.say === "mcp_server_response") {
continue
}
// Use consolidated version if available
const consolidatedMsg = consolidatedMessages.get(msg.ts)
if (consolidatedMsg) {
result.push(consolidatedMsg)
} else {
result.push(msg)
}
}
return result
}

View file

@ -0,0 +1,157 @@
import type { TokenUsage, ToolUsage, ToolName, ClineMessage } from "@roo-code/types"
export type ParsedApiReqStartedTextType = {
tokensIn: number
tokensOut: number
cacheWrites: number
cacheReads: number
cost?: number // Only present if consolidateApiRequests has been called
apiProtocol?: "anthropic" | "openai"
}
/**
* Consolidates token usage metrics from an array of ClineMessages.
*
* This function processes 'condense_context' messages and 'api_req_started' messages that have been
* consolidated with their corresponding 'api_req_finished' messages by the consolidateApiRequests function.
* It extracts and sums up the tokensIn, tokensOut, cacheWrites, cacheReads, and cost from these messages.
*
* @param messages - An array of ClineMessage objects to process.
* @returns A TokenUsage object containing totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost, and contextTokens.
*
* @example
* const messages = [
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data","tokensIn":10,"tokensOut":20,"cost":0.005}', ts: 1000 }
* ];
* const { totalTokensIn, totalTokensOut, totalCost } = consolidateTokenUsage(messages);
* // Result: { totalTokensIn: 10, totalTokensOut: 20, totalCost: 0.005 }
*/
export function consolidateTokenUsage(messages: ClineMessage[]): TokenUsage {
const result: TokenUsage = {
totalTokensIn: 0,
totalTokensOut: 0,
totalCacheWrites: undefined,
totalCacheReads: undefined,
totalCost: 0,
contextTokens: 0,
}
// Calculate running totals.
messages.forEach((message) => {
if (message.type === "say" && message.say === "api_req_started" && message.text) {
try {
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedText
if (typeof tokensIn === "number") {
result.totalTokensIn += tokensIn
}
if (typeof tokensOut === "number") {
result.totalTokensOut += tokensOut
}
if (typeof cacheWrites === "number") {
result.totalCacheWrites = (result.totalCacheWrites ?? 0) + cacheWrites
}
if (typeof cacheReads === "number") {
result.totalCacheReads = (result.totalCacheReads ?? 0) + cacheReads
}
if (typeof cost === "number") {
result.totalCost += cost
}
} catch (error) {
console.error("Error parsing JSON:", error)
}
} else if (message.type === "say" && message.say === "condense_context") {
result.totalCost += message.contextCondense?.cost ?? 0
}
})
// Calculate context tokens, from the last API request started or condense
// context message.
result.contextTokens = 0
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
if (message.type === "say" && message.say === "api_req_started" && message.text) {
try {
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
const { tokensIn, tokensOut } = parsedText
// Since tokensIn now stores TOTAL input tokens (including cache tokens),
// we no longer need to add cacheWrites and cacheReads separately.
// This applies to both Anthropic and OpenAI protocols.
result.contextTokens = (tokensIn || 0) + (tokensOut || 0)
} catch {
// Ignore JSON parse errors
continue
}
} else if (message.type === "say" && message.say === "condense_context") {
result.contextTokens = message.contextCondense?.newContextTokens ?? 0
}
if (result.contextTokens) {
break
}
}
return result
}
/**
* Check if token usage has changed by comparing relevant properties.
* @param current - Current token usage data
* @param snapshot - Previous snapshot to compare against
* @returns true if any relevant property has changed or snapshot is undefined
*/
export function hasTokenUsageChanged(current: TokenUsage, snapshot?: TokenUsage): boolean {
if (!snapshot) {
return true
}
const keysToCompare: (keyof TokenUsage)[] = [
"totalTokensIn",
"totalTokensOut",
"totalCacheWrites",
"totalCacheReads",
"totalCost",
"contextTokens",
]
return keysToCompare.some((key) => current[key] !== snapshot[key])
}
/**
* Check if tool usage has changed by comparing attempts and failures.
* @param current - Current tool usage data
* @param snapshot - Previous snapshot to compare against (undefined treated as empty)
* @returns true if any tool's attempts/failures have changed between current and snapshot
*/
export function hasToolUsageChanged(current: ToolUsage, snapshot?: ToolUsage): boolean {
// Treat undefined snapshot as empty object for consistent comparison
const effectiveSnapshot = snapshot ?? {}
const currentKeys = Object.keys(current) as ToolName[]
const snapshotKeys = Object.keys(effectiveSnapshot) as ToolName[]
// Check if number of tools changed
if (currentKeys.length !== snapshotKeys.length) {
return true
}
// Check if any tool's stats changed
return currentKeys.some((key) => {
const currentTool = current[key]
const snapshotTool = effectiveSnapshot[key]
if (!snapshotTool || !currentTool) {
return true
}
return currentTool.attempts !== snapshotTool.attempts || currentTool.failures !== snapshotTool.failures
})
}

View file

@ -0,0 +1,12 @@
export {
type ParsedApiReqStartedTextType,
consolidateTokenUsage,
hasTokenUsageChanged,
hasToolUsageChanged,
} from "./consolidateTokenUsage.js"
export { consolidateApiRequests } from "./consolidateApiRequests.js"
export { consolidateCommands, COMMAND_OUTPUT_STRING } from "./consolidateCommands.js"
export { safeJsonParse } from "./safeJsonParse.js"

View file

@ -0,0 +1,20 @@
/**
* Safely parses JSON without crashing on invalid input.
*
* @param jsonString The string to parse
* @param defaultValue Value to return if parsing fails
* @returns Parsed JSON object or defaultValue if parsing fails
*/
export function safeJsonParse<T>(jsonString: string | null | undefined, defaultValue?: T): T | undefined {
if (!jsonString) {
return defaultValue
}
try {
return JSON.parse(jsonString) as T
} catch (error) {
// Log the error to the console for debugging.
console.error("Error parsing JSON:", error)
return defaultValue
}
}

View file

@ -30,7 +30,7 @@
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"globals": "^16.3.0",
"tsup": "^8.3.5",
"tsup": "^8.4.0",
"vitest": "^3.2.3"
}
}

13
packages/types/src/git.ts Normal file
View file

@ -0,0 +1,13 @@
export interface GitRepositoryInfo {
repositoryUrl?: string
repositoryName?: string
defaultBranch?: string
}
export interface GitCommit {
hash: string
shortHash: string
subject: string
author: string
date: string
}

View file

@ -7,6 +7,7 @@ export * from "./custom-tool.js"
export * from "./events.js"
export * from "./experiment.js"
export * from "./followup.js"
export * from "./git.js"
export * from "./global-settings.js"
export * from "./history.js"
export * from "./image-generation.js"
@ -24,6 +25,7 @@ export * from "./terminal.js"
export * from "./tool.js"
export * from "./tool-params.js"
export * from "./type-fu.js"
export * from "./vscode-extension-host.js"
export * from "./vscode.js"
export * from "./providers/index.js"

View file

@ -86,3 +86,8 @@ export const installMarketplaceItemOptionsSchema = z.object({
})
export type InstallMarketplaceItemOptions = z.infer<typeof installMarketplaceItemOptionsSchema>
export interface MarketplaceInstalledMetadata {
project: Record<string, { type: string }>
global: Record<string, { type: string }>
}

View file

@ -1,8 +1,9 @@
import { z } from "zod"
/**
* MCP Server Use Types
* McpServerUse
*/
export interface McpServerUse {
type: string
serverName: string
@ -39,3 +40,91 @@ export const mcpExecutionStatusSchema = z.discriminatedUnion("status", [
])
export type McpExecutionStatus = z.infer<typeof mcpExecutionStatusSchema>
/**
* McpServer
*/
export type McpServer = {
name: string
config: string
status: "connected" | "connecting" | "disconnected"
error?: string
errorHistory?: McpErrorEntry[]
tools?: McpTool[]
resources?: McpResource[]
resourceTemplates?: McpResourceTemplate[]
disabled?: boolean
timeout?: number
source?: "global" | "project"
projectPath?: string
instructions?: string
}
export type McpTool = {
name: string
description?: string
inputSchema?: object
alwaysAllow?: boolean
enabledForPrompt?: boolean
}
export type McpResource = {
uri: string
name: string
mimeType?: string
description?: string
}
export type McpResourceTemplate = {
uriTemplate: string
name: string
description?: string
mimeType?: string
}
export type McpResourceResponse = {
_meta?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
contents: Array<{
uri: string
mimeType?: string
text?: string
blob?: string
}>
}
export type McpToolCallResponse = {
_meta?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
content: Array<
| {
type: "text"
text: string
}
| {
type: "image"
data: string
mimeType: string
}
| {
type: "audio"
data: string
mimeType: string
}
| {
type: "resource"
resource: {
uri: string
mimeType?: string
text?: string
blob?: string
}
}
>
isError?: boolean
}
export type McpErrorEntry = {
message: string
timestamp: number
level: "error" | "warn" | "info"
}

View file

@ -1,4 +1,5 @@
import { z } from "zod"
import { DynamicProvider, LocalProvider } from "./provider-settings.js"
/**
* ReasoningEffort
@ -140,3 +141,7 @@ export const modelInfoSchema = z.object({
})
export type ModelInfo = z.infer<typeof modelInfoSchema>
export type ModelRecord = Record<string, ModelInfo>
export type RouterModels = Record<DynamicProvider | LocalProvider, ModelRecord>

View file

@ -0,0 +1,644 @@
import { z } from "zod"
import type { GlobalSettings, RooCodeSettings } from "./global-settings.js"
import type { ProviderSettings, ProviderSettingsEntry } from "./provider-settings.js"
import type { HistoryItem } from "./history.js"
import type { ModeConfig, PromptComponent } from "./mode.js"
import type { TelemetrySetting } from "./telemetry.js"
import type { Experiments } from "./experiment.js"
import type { ClineMessage, QueuedMessage } from "./message.js"
import {
type MarketplaceItem,
type MarketplaceInstalledMetadata,
type InstallMarketplaceItemOptions,
marketplaceItemSchema,
} from "./marketplace.js"
import type { TodoItem } from "./todo.js"
import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js"
import type { SerializedCustomToolDefinition } from "./custom-tool.js"
import type { GitCommit } from "./git.js"
import type { McpServer } from "./mcp.js"
import type { ModelRecord, RouterModels } from "./model.js"
/**
* ExtensionMessage
* Extension -> Webview | CLI
*/
export interface ExtensionMessage {
type:
| "action"
| "state"
| "selectedImages"
| "theme"
| "workspaceUpdated"
| "invoke"
| "messageUpdated"
| "mcpServers"
| "enhancedPrompt"
| "commitSearchResults"
| "listApiConfig"
| "routerModels"
| "openAiModels"
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
| "huggingFaceModels"
| "vsCodeLmApiAvailable"
| "updatePrompt"
| "systemPrompt"
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
| "exportModeResult"
| "importModeResult"
| "checkRulesDirectoryResult"
| "deleteCustomModeCheck"
| "currentCheckpointUpdated"
| "checkpointInitWarning"
| "browserToolEnabled"
| "browserConnectionResult"
| "remoteBrowserEnabled"
| "ttsStart"
| "ttsStop"
| "maxReadFileLine"
| "fileSearchResults"
| "toggleApiConfigPin"
| "acceptInput"
| "setHistoryPreviewCollapsed"
| "commandExecutionStatus"
| "mcpExecutionStatus"
| "vsCodeSetting"
| "authenticatedUser"
| "condenseTaskContextStarted"
| "condenseTaskContextResponse"
| "singleRouterModelFetchResponse"
| "rooCreditBalance"
| "indexingStatusUpdate"
| "indexCleared"
| "codebaseIndexConfig"
| "marketplaceInstallResult"
| "marketplaceRemoveResult"
| "marketplaceData"
| "shareTaskSuccess"
| "codeIndexSettingsSaved"
| "codeIndexSecretStatus"
| "showDeleteMessageDialog"
| "showEditMessageDialog"
| "commands"
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
| "interactionRequired"
| "browserSessionUpdate"
| "browserSessionNavigate"
| "claudeCodeRateLimits"
| "customToolsResult"
| "modes"
text?: string
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
checkpointWarning?: {
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
timeout: number
}
action?:
| "chatButtonClicked"
| "settingsButtonClicked"
| "historyButtonClicked"
| "marketplaceButtonClicked"
| "cloudButtonClicked"
| "didBecomeVisible"
| "focusInput"
| "switchTab"
| "toggleAutoApprove"
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
state?: ExtensionState
images?: string[]
filePaths?: string[]
openedTabs?: Array<{
label: string
isActive: boolean
path?: string
}>
clineMessage?: ClineMessage
routerModels?: RouterModels
openAiModels?: string[]
ollamaModels?: ModelRecord
lmStudioModels?: ModelRecord
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
huggingFaceModels?: Array<{
id: string
object: string
created: number
owned_by: string
providers: Array<{
provider: string
status: "live" | "staging" | "error"
supports_tools?: boolean
supports_structured_output?: boolean
context_length?: number
pricing?: {
input: number
output: number
}
}>
}>
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
mode?: string
customMode?: ModeConfig
slug?: string
success?: boolean
values?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
requestId?: string
promptText?: string
results?:
| { path: string; type: "file" | "folder"; label?: string }[]
| { name: string; description?: string; argumentHint?: string; source: "global" | "project" | "built-in" }[]
error?: string
setting?: string
value?: any // eslint-disable-line @typescript-eslint/no-explicit-any
hasContent?: boolean
items?: MarketplaceItem[]
userInfo?: CloudUserInfo
organizationAllowList?: OrganizationAllowList
tab?: string
marketplaceItems?: MarketplaceItem[]
organizationMcps?: MarketplaceItem[]
marketplaceInstalledMetadata?: MarketplaceInstalledMetadata
errors?: string[]
visibility?: ShareVisibility
rulesFolderPath?: string
settings?: any // eslint-disable-line @typescript-eslint/no-explicit-any
messageTs?: number
hasCheckpoint?: boolean
context?: string
commands?: Command[]
queuedMessages?: QueuedMessage[]
list?: string[] // For dismissedUpsells
organizationId?: string | null // For organizationSwitchResult
browserSessionMessages?: ClineMessage[] // For browser session panel updates
isBrowserSessionActive?: boolean // For browser session panel updates
stepIndex?: number // For browserSessionNavigate: the target step index to display
tools?: SerializedCustomToolDefinition[] // For customToolsResult
modes?: { slug: string; name: string }[] // For modes response
}
export type ExtensionState = Pick<
GlobalSettings,
| "currentApiConfigName"
| "listApiConfigMeta"
| "pinnedApiConfigs"
| "customInstructions"
| "dismissedUpsells"
| "autoApprovalEnabled"
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
| "alwaysAllowWrite"
| "alwaysAllowWriteOutsideWorkspace"
| "alwaysAllowWriteProtected"
| "alwaysAllowBrowser"
| "alwaysAllowMcp"
| "alwaysAllowModeSwitch"
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowExecute"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "deniedCommands"
| "allowedMaxRequests"
| "allowedMaxCost"
| "browserToolEnabled"
| "browserViewportSize"
| "screenshotQuality"
| "remoteBrowserEnabled"
| "cachedChromeHostUrl"
| "remoteBrowserHost"
| "ttsEnabled"
| "ttsSpeed"
| "soundEnabled"
| "soundVolume"
| "maxConcurrentFileReads"
| "terminalOutputLineLimit"
| "terminalOutputCharacterLimit"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"
| "terminalPowershellCounter"
| "terminalZshClearEolMark"
| "terminalZshOhMy"
| "terminalZshP10k"
| "terminalZdotdir"
| "terminalCompressProgressBar"
| "diagnosticsEnabled"
| "diffEnabled"
| "fuzzyMatchThreshold"
| "language"
| "modeApiConfigs"
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "condensingApiConfigId"
| "customCondensingPrompt"
| "codebaseIndexConfig"
| "codebaseIndexModels"
| "profileThresholds"
| "includeDiagnosticMessages"
| "maxDiagnosticMessages"
| "imageGenerationProvider"
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requestDelaySeconds"
> & {
version: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
currentTaskTodos?: TodoItem[] // Initial todos for the current task
apiConfiguration: ProviderSettings
uriScheme?: string
shouldShowAnnouncement: boolean
taskHistory: HistoryItem[]
writeDelayMs: number
enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
enableSubfolderRules: boolean // Whether to load rules from subdirectories
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
maxImageFileSize: number // Maximum size of image files to process in MB
maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB
experiments: Experiments // Map of experiment IDs to their enabled state
mcpEnabled: boolean
enableMcpServerCreation: boolean
mode: string
customModes: ModeConfig[]
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
cwd?: string // Current working directory
telemetrySetting: TelemetrySetting
telemetryKey?: string
machineId?: string
renderContext: "sidebar" | "editor"
settingsImportedAt?: number
historyPreviewCollapsed?: boolean
cloudUserInfo: CloudUserInfo | null
cloudIsAuthenticated: boolean
cloudAuthSkipModel?: boolean // Flag indicating auth completed without model selection (user should pick 3rd-party provider)
cloudApiUrl?: string
cloudOrganizations?: CloudOrganizationMembership[]
sharingEnabled: boolean
publicSharingEnabled: boolean
organizationAllowList: OrganizationAllowList
organizationSettingsVersion?: number
isBrowserSessionActive: boolean // Actual browser session state
autoCondenseContext: boolean
autoCondenseContextPercent: number
marketplaceItems?: MarketplaceItem[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
openRouterImageApiKey?: string
messageQueue?: QueuedMessage[]
lastShownAnnouncementId?: string
apiModelId?: string
mcpServers?: McpServer[]
hasSystemPromptOverride?: boolean
mdmCompliant?: boolean
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
claudeCodeIsAuthenticated?: boolean
debug?: boolean
}
export interface Command {
name: string
source: "global" | "project" | "built-in"
filePath?: string
description?: string
argumentHint?: string
}
/**
* WebviewMessage
* Webview | CLI -> Extension
*/
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
export type AudioType = "notification" | "celebration" | "progress_loop"
export interface UpdateTodoListPayload {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
todos: any[]
}
export type EditQueuedMessagePayload = Pick<QueuedMessage, "id" | "text" | "images">
export interface WebviewMessage {
type:
| "updateTodoList"
| "deleteMultipleTasksWithIds"
| "currentApiConfigName"
| "saveApiConfiguration"
| "upsertApiConfiguration"
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "loadApiConfigurationById"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions"
| "webviewDidLaunch"
| "newTask"
| "askResponse"
| "terminalOperation"
| "clearTask"
| "didShowAnnouncement"
| "selectImages"
| "exportCurrentTask"
| "shareCurrentTask"
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "importSettings"
| "exportSettings"
| "resetState"
| "flushRouterModels"
| "requestRouterModels"
| "requestOpenAiModels"
| "requestOllamaModels"
| "requestLmStudioModels"
| "requestRooModels"
| "requestRooCreditBalance"
| "requestVsCodeLmModels"
| "requestHuggingFaceModels"
| "openImage"
| "saveImage"
| "openFile"
| "openMention"
| "cancelTask"
| "cancelAutoApproval"
| "updateVSCodeSetting"
| "getVSCodeSetting"
| "vsCodeSetting"
| "updateCondensingPrompt"
| "playSound"
| "playTts"
| "stopTts"
| "ttsEnabled"
| "ttsSpeed"
| "openKeyboardShortcuts"
| "openMcpSettings"
| "openProjectMcpSettings"
| "restartMcpServer"
| "refreshAllMcpServers"
| "toggleToolAlwaysAllow"
| "toggleToolEnabledForPrompt"
| "toggleMcpServer"
| "updateMcpTimeout"
| "enhancePrompt"
| "enhancedPrompt"
| "draggedImages"
| "deleteMessage"
| "deleteMessageConfirm"
| "submitEditedMessage"
| "editMessageConfirm"
| "enableMcpServerCreation"
| "remoteControlEnabled"
| "taskSyncEnabled"
| "searchCommits"
| "setApiConfigPassword"
| "mode"
| "updatePrompt"
| "getSystemPrompt"
| "copySystemPrompt"
| "systemPrompt"
| "enhancementApiConfigId"
| "autoApprovalEnabled"
| "updateCustomMode"
| "deleteCustomMode"
| "setopenAiCustomModelInfo"
| "openCustomModesSettings"
| "checkpointDiff"
| "checkpointRestore"
| "deleteMcpServer"
| "codebaseIndexEnabled"
| "telemetrySetting"
| "testBrowserConnection"
| "browserConnectionResult"
| "searchFiles"
| "toggleApiConfigPin"
| "hasOpenedModeSelector"
| "clearCloudAuthSkipModel"
| "cloudButtonClicked"
| "rooCloudSignIn"
| "cloudLandingPageSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
| "claudeCodeSignIn"
| "claudeCodeSignOut"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
| "startIndexing"
| "clearIndexData"
| "indexingStatusUpdate"
| "indexCleared"
| "focusPanelRequest"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
| "installMarketplaceItem"
| "installMarketplaceItemWithParameters"
| "cancelMarketplaceInstall"
| "removeInstalledMarketplaceItem"
| "marketplaceInstallResult"
| "fetchMarketplaceData"
| "switchTab"
| "shareTaskSuccess"
| "exportMode"
| "exportModeResult"
| "importMode"
| "importModeResult"
| "checkRulesDirectory"
| "checkRulesDirectoryResult"
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "requestCommands"
| "openCommandFile"
| "deleteCommand"
| "createCommand"
| "insertTextIntoTextarea"
| "showMdmAuthRequiredNotification"
| "imageGenerationSettings"
| "queueMessage"
| "removeQueuedMessage"
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "updateSettings"
| "allowedCommands"
| "deniedCommands"
| "killBrowserSession"
| "openBrowserSessionPanel"
| "showBrowserSessionPanelAtStep"
| "refreshBrowserSessionPanel"
| "browserPanelDidLaunch"
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
| "requestClaudeCodeRateLimits"
| "refreshCustomTools"
| "requestModes"
| "switchMode"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
disabled?: boolean
context?: string
dataUri?: string
askResponse?: ClineAskResponse
apiConfiguration?: ProviderSettings
images?: string[]
bool?: boolean
value?: number
stepIndex?: number
isLaunchAction?: boolean
forceShow?: boolean
commands?: string[]
audioType?: AudioType
serverName?: string
toolName?: string
alwaysAllow?: boolean
isEnabled?: boolean
mode?: string
promptMode?: string | "enhance"
customPrompt?: PromptComponent
dataUrls?: string[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values?: Record<string, any>
query?: string
setting?: string
slug?: string
modeConfig?: ModeConfig
timeout?: number
payload?: WebViewMessagePayload
source?: "global" | "project"
requestId?: string
ids?: string[]
hasSystemPromptOverride?: boolean
terminalOperation?: "continue" | "abort"
messageTs?: number
restoreCheckpoint?: boolean
historyPreviewCollapsed?: boolean
filters?: { type?: string; search?: string; tags?: string[] }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
settings?: any
url?: string // For openExternal
mpItem?: MarketplaceItem
mpInstallOptions?: InstallMarketplaceItemOptions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
config?: Record<string, any> // Add config to the payload
visibility?: ShareVisibility // For share visibility
hasContent?: boolean // For checkRulesDirectoryResult
checkOnly?: boolean // For deleteCustomMode check
upsellId?: string // For dismissUpsell
list?: string[] // For dismissedUpsells response
organizationId?: string | null // For organization switching
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean
codebaseIndexQdrantUrl: string
codebaseIndexEmbedderProvider:
| "openai"
| "ollama"
| "openai-compatible"
| "gemini"
| "mistral"
| "vercel-ai-gateway"
| "bedrock"
| "openrouter"
codebaseIndexEmbedderBaseUrl?: string
codebaseIndexEmbedderModelId: string
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
codebaseIndexOpenAiCompatibleBaseUrl?: string
codebaseIndexBedrockRegion?: string
codebaseIndexBedrockProfile?: string
codebaseIndexSearchMaxResults?: number
codebaseIndexSearchMinScore?: number
codebaseIndexOpenRouterSpecificProvider?: string // OpenRouter provider routing
// Secret settings
codeIndexOpenAiKey?: string
codeIndexQdrantApiKey?: string
codebaseIndexOpenAiCompatibleApiKey?: string
codebaseIndexGeminiApiKey?: string
codebaseIndexMistralApiKey?: string
codebaseIndexVercelAiGatewayApiKey?: string
codebaseIndexOpenRouterApiKey?: string
}
updatedSettings?: RooCodeSettings
}
export const checkoutDiffPayloadSchema = z.object({
ts: z.number().optional(),
previousCommitHash: z.string().optional(),
commitHash: z.string(),
mode: z.enum(["full", "checkpoint", "from-init", "to-current"]),
})
export type CheckpointDiffPayload = z.infer<typeof checkoutDiffPayloadSchema>
export const checkoutRestorePayloadSchema = z.object({
ts: z.number(),
commitHash: z.string(),
mode: z.enum(["preview", "restore"]),
})
export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
export interface IndexingStatusPayload {
state: "Standby" | "Indexing" | "Indexed" | "Error"
message: string
}
export interface IndexClearedPayload {
success: boolean
error?: string
}
export const installMarketplaceItemWithParametersPayloadSchema = z.object({
item: marketplaceItemSchema,
parameters: z.record(z.string(), z.any()),
})
export type InstallMarketplaceItemWithParametersPayload = z.infer<
typeof installMarketplaceItemWithParametersPayloadSchema
>
export type WebViewMessagePayload =
| CheckpointDiffPayload
| CheckpointRestorePayload
| IndexingStatusPayload
| IndexClearedPayload
| InstallMarketplaceItemWithParametersPayload
| UpdateTodoListPayload
| EditQueuedMessagePayload

View file

@ -68,6 +68,13 @@ export interface VSCodeAPIMockOptions {
* Defaults to the directory containing this module.
*/
appRoot?: string
/**
* Custom storage directory for persistent state.
* Defaults to ~/.vscode-mock.
* Set to a temp directory for ephemeral/no-persist mode.
*/
storageDir?: string
}
/**
@ -82,6 +89,7 @@ export function createVSCodeAPIMock(
const context = new ExtensionContextImpl({
extensionPath: extensionRootPath,
workspacePath: workspacePath,
storageDir: options?.storageDir,
})
const workspace = new WorkspaceAPI(workspacePath, context)
const window = new WindowAPI()

503
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,13 @@ import { z } from "zod"
import {
type ModelInfo,
type ModelRecord,
HUGGINGFACE_API_URL,
HUGGINGFACE_CACHE_DURATION,
HUGGINGFACE_DEFAULT_MAX_TOKENS,
HUGGINGFACE_DEFAULT_CONTEXT_WINDOW,
} from "@roo-code/types"
import type { ModelRecord } from "../../../shared/api"
const huggingFaceProviderSchema = z.object({
provider: z.string(),
status: z.enum(["live", "staging", "error"]),

View file

@ -1,9 +1,7 @@
import axios from "axios"
import { z } from "zod"
import { type ModelInfo, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
import type { ModelRecord } from "../../../shared/api"
import { type ModelInfo, type ModelRecord, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
const ioIntelligenceModelSchema = z.object({
id: z.string(),

View file

@ -1,6 +1,6 @@
import axios from "axios"
import type { ModelRecord } from "../../../shared/api"
import type { ModelRecord } from "@roo-code/types"
import { DEFAULT_HEADERS } from "../constants"
/**

View file

@ -5,7 +5,7 @@ import * as fsSync from "fs"
import NodeCache from "node-cache"
import { z } from "zod"
import type { ProviderName } from "@roo-code/types"
import type { ProviderName, ModelRecord } from "@roo-code/types"
import { modelInfoSchema, TelemetryEventName } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
@ -13,7 +13,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson"
import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../utils/storage"
import type { RouterName, ModelRecord } from "../../../shared/api"
import type { RouterName } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"
import { getOpenRouterModels } from "./openrouter"

View file

@ -2,13 +2,15 @@ import * as path from "path"
import fs from "fs/promises"
import NodeCache from "node-cache"
import { safeWriteJson } from "../../../utils/safeWriteJson"
import sanitize from "sanitize-filename"
import type { ModelRecord } from "@roo-code/types"
import { ContextProxy } from "../../../core/config/ContextProxy"
import { RouterName } from "../../../shared/api"
import { getCacheDirectoryPath } from "../../../utils/storage"
import { RouterName, ModelRecord } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"
import { safeWriteJson } from "../../../utils/safeWriteJson"
import { getOpenRouterModelEndpoints } from "./openrouter"
import { getModels } from "./modelCache"

View file

@ -1,6 +1,5 @@
import { RooModelsResponseSchema, type ModelInfo } from "@roo-code/types"
import { RooModelsResponseSchema, type ModelInfo, type ModelRecord } from "@roo-code/types"
import type { ModelRecord } from "../../../shared/api"
import { parseApiPrice } from "../../../shared/cost"
import { DEFAULT_HEADERS } from "../constants"

View file

@ -1,7 +1,9 @@
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import type { ModelRecord } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"

View file

@ -3,18 +3,19 @@ import OpenAI from "openai"
import { z } from "zod"
import {
type ModelRecord,
ApiProviderError,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
OPENROUTER_DEFAULT_PROVIDER_NAME,
OPEN_ROUTER_PROMPT_CACHING_MODELS,
DEEP_SEEK_DEFAULT_TEMPERATURE,
ApiProviderError,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { normalizeMistralToolCallId } from "../transform/mistral-format"

View file

@ -3,13 +3,14 @@ import OpenAI from "openai"
import {
type ModelInfo,
type ModelRecord,
requestyDefaultModelId,
requestyDefaultModelInfo,
TOOL_PROTOCOL,
NATIVE_TOOL_DEFAULTS,
} from "@roo-code/types"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import type { ApiHandlerOptions } from "../../shared/api"
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
import { calculateApiCostOpenAI } from "../../shared/cost"

View file

@ -2,11 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod } from "@roo-code/types"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
import { CloudService } from "@roo-code/cloud"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
import { Package } from "../../shared/package"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import type { ApiHandlerOptions } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { convertToOpenAiMessages } from "../transform/openai-format"

View file

@ -1,8 +1,8 @@
import OpenAI from "openai"
import { type ModelInfo, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
import { type ModelInfo, type ModelRecord, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
import { ApiHandlerOptions, RouterName, ModelRecord } from "../../shared/api"
import { ApiHandlerOptions, RouterName } from "../../shared/api"
import { BaseProvider } from "./base-provider"
import { getModels, getModelsFromCache } from "./fetchers/modelCache"

View file

@ -1,6 +1,12 @@
import { type ClineAsk, type McpServerUse, type FollowUpData, isNonBlockingAsk } from "@roo-code/types"
import {
type ClineAsk,
type McpServerUse,
type FollowUpData,
type ExtensionState,
isNonBlockingAsk,
} from "@roo-code/types"
import type { ClineSayTool, ExtensionState } from "../../shared/ExtensionMessage"
import type { ClineSayTool } from "../../shared/ExtensionMessage"
import { ClineAskResponse } from "../../shared/WebviewMessage"
import { isWriteToolAction, isReadOnlyToolAction } from "./tools"

View file

@ -1,6 +1,4 @@
import type { McpServerUse } from "@roo-code/types"
import type { McpServer, McpTool } from "../../shared/mcp"
import type { McpServerUse, McpServer, McpTool } from "@roo-code/types"
export function isMcpToolAlwaysAllowed(mcpServerUse: McpServerUse, mcpServers: McpServer[] | undefined): boolean {
if (mcpServerUse.type === "use_mcp_tool" && mcpServerUse.toolName) {

View file

@ -1,7 +1,10 @@
import type OpenAI from "openai"
import { getMcpServerTools } from "../mcp_server"
import type { McpServer, McpTool } from "@roo-code/types"
import type { McpHub } from "../../../../../services/mcp/McpHub"
import type { McpServer, McpTool } from "../../../../../shared/mcp"
import { getMcpServerTools } from "../mcp_server"
// Helper type to access function tools
type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }

View file

@ -1688,6 +1688,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
private async resumeTaskFromHistory() {
// Reset abort and streaming state to ensure clean continuation.
// This matches the behavior in resumeAfterDelegation() and prevents
// corrupted state from a previous cancellation.
this.abort = false
this.abandoned = false
this.abortReason = undefined
this.didFinishAbortingStream = false
this.isStreaming = false
this.isWaitingForFirstChunk = false
if (this.enableBridge) {
try {
await BridgeOrchestrator.subscribeToTask(this)
@ -1788,7 +1798,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.isInitialized = true
const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
let response: ClineAskResponse
let text: string | undefined
let images: string[] | undefined
try {
const result = await this.ask(askType) // Calls `postStateToWebview`.
response = result.response
text = result.text
images = result.images
} catch (error) {
// Handle abort gracefully - if task was aborted during the ask, don't throw
if (this.abort) {
return
}
throw error
}
let responseText: string | undefined
let responseImages: string[] | undefined
@ -1973,7 +1998,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
// Task resuming from history item.
await this.initiateTaskLoop(newUserContent)
await this.initiateTaskLoop(newUserContent).catch((error) => {
// Swallow loop rejection when the task was intentionally abandoned/aborted
// during delegation or user cancellation to prevent unhandled rejections.
if (this.abandoned === true || this.abortReason === "user_cancelled") {
return
}
throw error
})
}
/**

View file

@ -34,6 +34,9 @@ import {
type CreateTaskOptions,
type TokenUsage,
type ToolUsage,
type ExtensionMessage,
type ExtensionState,
type MarketplaceInstalledMetadata,
RooCodeEventName,
requestyDefaultModelId,
openRouterDefaultModelId,
@ -51,7 +54,6 @@ import { Package } from "../../shared/package"
import { findLast } from "../../shared/array"
import { supportPrompt } from "../../shared/support-prompt"
import { GlobalFileNames } from "../../shared/globalFileNames"
import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { experimentDefault } from "../../shared/experiments"
import { formatLanguage } from "../../shared/language"
@ -902,7 +904,18 @@ export class ClineProvider
if (profile?.name) {
try {
await this.activateProviderProfile({ name: profile.name })
// Check if the profile has actual API configuration (not just an id).
// In CLI mode, the ProviderSettingsManager may return empty default profiles
// that only contain 'id' and 'name' fields. Activating such a profile would
// overwrite the CLI's working API configuration with empty settings.
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
const hasActualSettings = !!fullProfile.apiProvider
if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
} else {
// The task will continue with the current/default configuration.
}
} catch (error) {
// Log the error but continue with task restoration.
this.log(
@ -910,7 +923,6 @@ export class ClineProvider
error instanceof Error ? error.message : String(error)
}. Continuing with default configuration.`,
)
// The task will continue with the current/default configuration.
}
}
}
@ -1285,14 +1297,29 @@ export class ClineProvider
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
if (profile?.name) {
await this.activateProviderProfile({ name: profile.name })
// Check if the profile has actual API configuration (not just an id).
// In CLI mode, the ProviderSettingsManager may return empty default profiles
// that only contain 'id' and 'name' fields. Activating such a profile would
// overwrite the CLI's working API configuration with empty settings.
// Skip activation if the profile has no apiProvider set - this indicates
// an unconfigured/empty profile.
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
const hasActualSettings = !!fullProfile.apiProvider
if (hasActualSettings) {
await this.activateProviderProfile({ name: profile.name })
} else {
// The task will continue with the current/default configuration.
}
} else {
// The task will continue with the current/default configuration.
}
} else {
// If no saved config for this mode, save current config as default.
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName")
if (currentApiConfigName) {
const config = listApiConfig.find((c) => c.name === currentApiConfigName)
if (currentApiConfigNameAfter) {
const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter)
if (config?.id) {
await this.providerSettingsManager.setModeConfig(newMode, config.id)
@ -1453,6 +1480,7 @@ export class ClineProvider
if (id) {
await this.providerSettingsManager.setModeConfig(mode, id)
}
// Change the provider for the current task.
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })

View file

@ -7,12 +7,13 @@ import axios from "axios"
import {
type ProviderSettingsEntry,
type ClineMessage,
type ExtensionMessage,
type ExtensionState,
ORGANIZATION_ALLOW_ALL,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
} from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
import { defaultModeSlug } from "../../../shared/modes"
import { experimentDefault } from "../../../shared/experiments"
import { setTtsEnabled } from "../../../utils/tts"
@ -888,6 +889,7 @@ describe("ClineProvider", () => {
listConfig: vi.fn().mockResolvedValue([profile]),
activateProfile: vi.fn().mockResolvedValue(profile),
setModeConfig: vi.fn(),
getProfile: vi.fn().mockResolvedValue(profile),
} as any
// Switch to architect mode
@ -1609,6 +1611,7 @@ describe("ClineProvider", () => {
listConfig: vi.fn().mockResolvedValue([profile]),
activateProfile: vi.fn().mockResolvedValue(profile),
setModeConfig: vi.fn(),
getProfile: vi.fn().mockResolvedValue(profile),
} as any
// Switch to architect mode

View file

@ -10,10 +10,11 @@ vi.mock("../diagnosticsHandler", () => ({
generateErrorDiagnostics: vi.fn().mockResolvedValue({ success: true, filePath: "/tmp/diagnostics.json" }),
}))
import type { ModelRecord } from "@roo-code/types"
import { webviewMessageHandler } from "../webviewMessageHandler"
import type { ClineProvider } from "../ClineProvider"
import { getModels } from "../../../api/providers/fetchers/modelCache"
import type { ModelRecord } from "../../../shared/api"
const mockGetModels = getModels as Mock<typeof getModels>

View file

@ -12,9 +12,14 @@ import {
type ClineMessage,
type TelemetrySetting,
type UserSettingsConfig,
type ModelRecord,
type WebviewMessage,
type EditQueuedMessagePayload,
TelemetryEventName,
RooCodeSettings,
ExperimentId,
checkoutDiffPayloadSchema,
checkoutRestorePayloadSchema,
} from "@roo-code/types"
import { customToolRegistry } from "@roo-code/core"
import { CloudService } from "@roo-code/cloud"
@ -29,15 +34,9 @@ import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { generateErrorDiagnostics } from "./diagnosticsHandler"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { type RouterName, type ModelRecord, toRouterName } from "../../shared/api"
import { type RouterName, toRouterName } from "../../shared/api"
import { MessageEnhancer } from "./messageEnhancer"
import {
type WebviewMessage,
type EditQueuedMessagePayload,
checkoutDiffPayloadSchema,
checkoutRestorePayloadSchema,
} from "../../shared/WebviewMessage"
import { checkExistKey } from "../../shared/checkExistApiConfig"
import { experimentDefault } from "../../shared/experiments"
import { Terminal } from "../../integrations/terminal/Terminal"
@ -2828,7 +2827,7 @@ export const webviewMessageHandler = async (
case "switchTab": {
if (message.tab) {
// Capture tab shown event for all switchTab messages (which are user-initiated)
// Capture tab shown event for all switchTab messages (which are user-initiated).
if (TelemetryService.hasInstance()) {
TelemetryService.instance.captureTabShown(message.tab)
}
@ -2847,7 +2846,6 @@ export const webviewMessageHandler = async (
const { getCommands } = await import("../../services/command/commands")
const commands = await getCommands(getCurrentCwd())
// Convert to the format expected by the frontend
const commandList = commands.map((command) => ({
name: command.name,
source: command.source,
@ -2856,17 +2854,26 @@ export const webviewMessageHandler = async (
argumentHint: command.argumentHint,
}))
await provider.postMessageToWebview({
type: "commands",
commands: commandList,
})
await provider.postMessageToWebview({ type: "commands", commands: commandList })
} catch (error) {
provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
// Send empty array on error
await provider.postMessageToWebview({
type: "commands",
commands: [],
})
await provider.postMessageToWebview({ type: "commands", commands: [] })
}
break
}
case "requestModes": {
try {
const modes = await provider.getModes()
await provider.postMessageToWebview({ type: "modes", modes })
} catch (error) {
provider.log(`Error fetching modes: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
await provider.postMessageToWebview({ type: "modes", modes: [] })
}
break
}
case "switchMode": {
if (message.mode) {
await provider.handleModeSwitch(message.mode as Mode)
}
break
}

Some files were not shown because too many files have changed in this diff Show more