Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2026-01-09 19:19:58 -08:00 committed by GitHub
parent 1e62b5ddf1
commit ea9717d7ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 20536 additions and 3168 deletions

116
apps/cli/CHANGELOG.md Normal file
View file

@ -0,0 +1,116 @@
# Changelog
All notable changes to the `@roo-code/cli` package will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.0.45] - 2026-01-08
### Changed
- **Major Refactor**: Extracted ~1400 lines from [`App.tsx`](src/ui/App.tsx) into reusable hooks and utilities for better maintainability:
- [`useExtensionHost`](src/ui/hooks/useExtensionHost.ts) - Extension host connection and lifecycle management
- [`useMessageHandlers`](src/ui/hooks/useMessageHandlers.ts) - Message processing and state updates
- [`useTaskSubmit`](src/ui/hooks/useTaskSubmit.ts) - Task submission logic
- [`useGlobalInput`](src/ui/hooks/useGlobalInput.ts) - Global keyboard shortcut handling
- [`useFollowupCountdown`](src/ui/hooks/useFollowupCountdown.ts) - Auto-approval countdown logic
- [`useFocusManagement`](src/ui/hooks/useFocusManagement.ts) - Input focus state management
- [`usePickerHandlers`](src/ui/hooks/usePickerHandlers.ts) - Picker component event handling
- [`uiStateStore`](src/ui/stores/uiStateStore.ts) - UI-specific state (showExitHint, countdown, etc.)
- Tool data utilities ([`extractToolData`](src/ui/utils/toolDataUtils.ts), `formatToolOutput`, etc.)
- [`HorizontalLine`](src/ui/components/HorizontalLine.tsx) component
- **Performance Optimizations**:
- Added RAF-style scroll throttling to reduce state updates
- Stabilized `useExtensionHost` hook return values with `useCallback`/`useMemo`
- Added streaming message debouncing to batch rapid partial updates
- Added shallow array equality checks to prevent unnecessary re-renders
- Simplified [`ModeTool`](src/ui/components/tools/ModeTool.tsx) layout to horizontal with mode suffix
- Simplified logging by removing verbose debug output and adding first/last partial message logging pattern
- Updated Nerd Font icon codepoints in [`Icon`](src/ui/components/Icon.tsx) component
### Added
- `#` shortcut in help trigger for quick access to task history autocomplete
### Fixed
- Fixed a crash in message handling
- Added protected file warning in tool approval prompts
- Enabled `alwaysAllowWriteProtected` for non-interactive mode
### Removed
- Removed unused `renderLogger.ts` utility file
### Tests
- Updated extension-host tests to expect `[Tool Request]` format
- Updated Icon tests to expect single-char Nerd Font icons
## [0.0.44] - 2026-01-08
### Added
- **Tool Renderer Components**: Specialized renderers for displaying tool outputs with optimized formatting for each tool type. Each renderer provides a focused view of its data structure.
- [`FileReadTool`](src/ui/components/tools/FileReadTool.tsx) - Display file read operations with syntax highlighting
- [`FileWriteTool`](src/ui/components/tools/FileWriteTool.tsx) - Show file write/edit operations with diff views
- [`SearchTool`](src/ui/components/tools/SearchTool.tsx) - Render search results with context
- [`CommandTool`](src/ui/components/tools/CommandTool.tsx) - Display command execution with output
- [`BrowserTool`](src/ui/components/tools/BrowserTool.tsx) - Show browser automation actions
- [`ModeTool`](src/ui/components/tools/ModeTool.tsx) - Display mode switching operations
- [`CompletionTool`](src/ui/components/tools/CompletionTool.tsx) - Show task completion status
- [`GenericTool`](src/ui/components/tools/GenericTool.tsx) - Fallback renderer for other tools
- **History Trigger**: New `#` trigger for task history autocomplete with fuzzy search support. Type `#` at the start of a line to browse and resume previous tasks.
- [`HistoryTrigger.tsx`](src/ui/components/autocomplete/triggers/HistoryTrigger.tsx) - Trigger implementation with fuzzy filtering
- Shows task status, mode, and relative timestamps
- Supports keyboard navigation for quick task selection
- **Release Confirmation Prompt**: The release script now prompts for confirmation before creating a release.
### Fixed
- Task history picker selection and navigation issues
- Mode switcher keyboard handling bug
### Changed
- Reorganized test files into `__tests__` directories for better project structure
- Refactored utility modules into dedicated `utils/` directory
## [0.0.43] - 2026-01-07
### Added
- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store.
- New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages
- New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications
- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components.
- New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module
- Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility
- Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling)
- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds.
### Changed
- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling.
### Tests
- Added comprehensive tests for the toast notification system
- Added tests for global input sequence matching
## [0.0.42] - 2025-01-07
The cli is alive!

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,32 +92,84 @@ 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:
- Tool, command, browser, and MCP actions are auto-approved
- Followup questions show a 10-second timeout, then auto-select the first suggestion
- Followup questions show a 60-second timeout, then auto-select the first suggestion
- Typing any key cancels the timeout and allows manual input
### Roo Code Cloud Authentication
To use Roo Code Cloud features (like the provider proxy), you need to authenticate:
```bash
# Log in to Roo Code Cloud (opens browser)
roo auth login
# Check authentication status
roo auth status
# Log out
roo auth logout
```
The `auth login` command:
1. Opens your browser to authenticate with Roo Code Cloud
2. Receives a secure token via localhost callback
3. Stores the token in `~/.config/roo/credentials.json`
Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when your token expires.
**Authentication Flow:**
```
┌──────┐ ┌─────────┐ ┌───────────────┐
│ CLI │ │ Browser │ │ Roo Code Cloud│
└──┬───┘ └────┬────┘ └───────┬───────┘
│ │ │
│ Open auth URL │ │
│─────────────────>│ │
│ │ │
│ │ Authenticate │
│ │─────────────────────>│
│ │ │
│ │<─────────────────────│
│ │ Token via callback │
<─────────────────│ │
│ │ │
│ Store token │ │
│ │ │
```
## Options
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-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` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | Provider default |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
| Option | Description | Default |
| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- |
| `[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 |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
| `--no-tui` | Disable TUI, use plain text output | `false` |
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
## Auth Commands
| Command | Description |
| ----------------- | ---------------------------------- |
| `roo auth login` | Authenticate with Roo Code Cloud |
| `roo auth logout` | Clear stored authentication token |
| `roo auth status` | Show current authentication status |
## Environment Variables
@ -123,9 +181,13 @@ The CLI will look for API keys in environment variables if not provided via `--a
| openai | `OPENAI_API_KEY` |
| openrouter | `OPENROUTER_API_KEY` |
| google/gemini | `GOOGLE_API_KEY` |
| mistral | `MISTRAL_API_KEY` |
| deepseek | `DEEPSEEK_API_KEY` |
| bedrock | `AWS_ACCESS_KEY_ID` |
| ... | ... |
**Authentication Environment Variables:**
| Variable | Description |
| ----------------- | -------------------------------------------------------------------- |
| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) |
## Architecture
@ -166,12 +228,6 @@ The CLI will look for API keys in environment variables if not provided via `--a
- CLI → Extension: `emit("webviewMessage", {...})`
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
## Current Limitations
- **No TUI**: Output is plain text (no React/Ink UI yet)
- **No configuration file**: Settings are passed via command line flags
- **No persistence**: Each run is a fresh session
## Development
```bash
@ -190,42 +246,17 @@ pnpm lint
## Releasing
To create a new release, run the release script from the monorepo root:
To create a new release, execute the /cli-release slash command:
```bash
# Release using version from package.json
./apps/cli/scripts/release.sh
# Release with a specific version
./apps/cli/scripts/release.sh 0.1.0
roo ~/Documents/Roo-Code -P "/cli-release" -y
```
The script will:
The workflow will:
1. Build the extension and CLI
2. Create a platform-specific tarball (for your current OS/architecture)
3. Create a GitHub release with the tarball attached
**Prerequisites:**
- GitHub CLI (`gh`) installed and authenticated (`gh auth login`)
- pnpm installed
## Troubleshooting
### Extension bundle not found
Make sure you've built the main extension first:
```bash
cd src
pnpm bundle
```
### Module resolution errors
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
### "vscode" module not found
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.
1. Bump the version
2. Update the CHANGELOG
3. Build the extension and CLI
4. Create a platform-specific tarball (for your current OS/architecture)
5. Test the install script
6. Create a GitHub release with the tarball attached

View file

@ -0,0 +1,456 @@
# Agent Loop State Detection in the Roo Code Webview Client
This document explains how the webview client detects when the agent loop has stopped and is waiting on the client to resume. This is essential knowledge for implementing an alternative client.
## Overview
The Roo Code extension uses a message-based architecture where the extension host (server) communicates with the webview client through typed messages. The agent loop state is determined by analyzing the `clineMessages` array in the extension state, specifically looking at the **last message's type and properties**.
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Extension Host (Server) │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │
│ │ Task.ts │────────▶│ RooCodeEventName events │ │
│ └─────────────┘ │ • TaskActive • TaskInteractive │ │
│ │ • TaskIdle • TaskResumable │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│ postMessage("state")
┌─────────────────────────────────────────────────────────────────────────────┐
│ Webview Client │
│ │
│ ┌──────────────────────┐ ┌─────────────────────┐ │
│ │ ExtensionStateContext│─────▶│ ChatView.tsx │ │
│ │ clineMessages[] │ │ │ │
│ └──────────────────────┘ │ ┌───────────────┐ │ │
│ │ │lastMessage │ │ │
│ │ │ .type │ │ │
│ │ │ .ask / .say │ │ │
│ │ │ .partial │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ State Detection│ │ │
│ │ │ Logic │ │ │
│ │ └───────┬───────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────┐ │ │
│ │ │ UI State │ │ │
│ │ │ • clineAsk │ │ │
│ │ │ • buttons │ │ │
│ │ └───────────────┘ │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Key Message Types
### ClineMessage Structure
Defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
```typescript
interface ClineMessage {
ts: number // Timestamp identifier
type: "ask" | "say" // Message category
ask?: ClineAsk // Ask type (when type="ask")
say?: ClineSay // Say type (when type="say")
text?: string // Message content
partial?: boolean // Is streaming incomplete?
// ... other fields
}
```
## Ask Type Categories
The `ClineAsk` types are categorized into four groups that determine when the agent is waiting. These are defined in [`packages/types/src/message.ts`](../packages/types/src/message.ts):
### 1. Idle Asks - Task effectively finished
These indicate the agent loop has stopped and the task is in a terminal or error state.
```typescript
const idleAsks = [
"completion_result", // Task completed successfully
"api_req_failed", // API request failed
"resume_completed_task", // Resume a completed task
"mistake_limit_reached", // Too many errors encountered
"auto_approval_max_req_reached", // Auto-approval limit hit
] as const
```
**Helper function:** `isIdleAsk(ask: ClineAsk): boolean`
### 2. Interactive Asks - Approval needed
These indicate the agent is waiting for user approval or input to proceed.
```typescript
const interactiveAsks = [
"followup", // Follow-up question asked
"command", // Permission to execute command
"tool", // Permission for file operations
"browser_action_launch", // Permission to use browser
"use_mcp_server", // Permission for MCP server
] as const
```
**Helper function:** `isInteractiveAsk(ask: ClineAsk): boolean`
### 3. Resumable Asks - Task paused
These indicate the task is paused and can be resumed.
```typescript
const resumableAsks = ["resume_task"] as const
```
**Helper function:** `isResumableAsk(ask: ClineAsk): boolean`
### 4. Non-Blocking Asks - No actual approval needed
These are informational and don't block the agent loop.
```typescript
const nonBlockingAsks = ["command_output"] as const
```
**Helper function:** `isNonBlockingAsk(ask: ClineAsk): boolean`
## Client-Side State Detection
### ChatView State Management
The [`ChatView`](../webview-ui/src/components/chat/ChatView.tsx) component maintains several state variables:
```typescript
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
const [sendingDisabled, setSendingDisabled] = useState(false)
```
### Detection Logic
The state is determined by a `useDeepCompareEffect` that watches `lastMessage` and `secondLastMessage`:
```typescript
useDeepCompareEffect(() => {
if (lastMessage) {
switch (lastMessage.type) {
case "ask":
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
// Agent loop stopped - API failed, needs retry or new task
setSendingDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
break
case "mistake_limit_reached":
// Agent loop stopped - too many errors
setSendingDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
break
case "followup":
// Agent loop stopped - waiting for user answer
setSendingDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(true)
break
case "tool":
case "command":
case "browser_action_launch":
case "use_mcp_server":
// Agent loop stopped - waiting for approval
setSendingDisabled(isPartial)
setClineAsk(lastMessage.ask)
setEnableButtons(!isPartial)
break
case "completion_result":
// Agent loop stopped - task complete
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
break
case "resume_task":
case "resume_completed_task":
// Agent loop stopped - task paused/completed
setSendingDisabled(false)
setClineAsk(lastMessage.ask)
setEnableButtons(true)
break
}
break
}
}
}, [lastMessage, secondLastMessage])
```
### Streaming Detection
To determine if the agent is still streaming a response:
```typescript
const isStreaming = useMemo(() => {
// Check if current ask has buttons visible
const isLastAsk = !!modifiedMessages.at(-1)?.ask
const isToolCurrentlyAsking =
isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
if (isToolCurrentlyAsking) return false
// Check if message is partial (still streaming)
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
if (isLastMessagePartial) return true
// Check if last API request finished (has cost)
const lastApiReqStarted = findLast(modifiedMessages, (m) => m.say === "api_req_started")
if (lastApiReqStarted?.text) {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) return true // Still streaming
}
return false
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
```
## Implementing State Detection in an Alternative Client
### Step 1: Subscribe to State Updates
```typescript
// Listen for state messages from extension
window.addEventListener("message", (event) => {
const message = event.data
if (message.type === "state") {
const clineMessages = message.state.clineMessages
detectAgentState(clineMessages)
}
})
```
### Step 2: Detect Agent State
```typescript
type AgentLoopState =
| "running" // Agent is actively processing
| "streaming" // Agent is streaming a response
| "interactive" // Waiting for tool/command approval
| "followup" // Waiting for user to answer a question
| "idle" // Task completed or errored out
| "resumable" // Task paused, can be resumed
function detectAgentState(messages: ClineMessage[]): AgentLoopState {
const lastMessage = messages.at(-1)
if (!lastMessage) return "running"
// Check if still streaming
if (lastMessage.partial === true) {
return "streaming"
}
// Check if it's an ask message
if (lastMessage.type === "ask" && lastMessage.ask) {
const ask = lastMessage.ask
// Idle states - task effectively stopped
if (
[
"completion_result",
"api_req_failed",
"resume_completed_task",
"mistake_limit_reached",
"auto_approval_max_req_reached",
].includes(ask)
) {
return "idle"
}
// Resumable state
if (ask === "resume_task") {
return "resumable"
}
// Follow-up question
if (ask === "followup") {
return "followup"
}
// Interactive approval needed
if (["command", "tool", "browser_action_launch", "use_mcp_server"].includes(ask)) {
return "interactive"
}
// Non-blocking (command_output)
if (ask === "command_output") {
return "running" // Can proceed or interrupt
}
}
// Check for API request in progress
const lastApiReq = messages.findLast((m) => m.say === "api_req_started")
if (lastApiReq?.text) {
try {
const data = JSON.parse(lastApiReq.text)
if (data.cost === undefined) {
return "streaming"
}
} catch {}
}
return "running"
}
```
### Step 3: Respond to Agent State
```typescript
// Send response back to extension
function respondToAsk(response: ClineAskResponse, text?: string, images?: string[]) {
vscode.postMessage({
type: "askResponse",
askResponse: response, // "yesButtonClicked" | "noButtonClicked" | "messageResponse"
text,
images,
})
}
// Start a new task
function startNewTask(text: string, images?: string[]) {
vscode.postMessage({
type: "newTask",
text,
images,
})
}
// Clear current task
function clearTask() {
vscode.postMessage({ type: "clearTask" })
}
// Cancel streaming task
function cancelTask() {
vscode.postMessage({ type: "cancelTask" })
}
// Terminal operations for command_output
function terminalOperation(operation: "continue" | "abort") {
vscode.postMessage({ type: "terminalOperation", terminalOperation: operation })
}
```
## Response Actions by State
| State | Primary Action | Secondary Action |
| ----------------------- | ---------------------------- | -------------------------- |
| `api_req_failed` | Retry (`yesButtonClicked`) | New Task (`clearTask`) |
| `mistake_limit_reached` | Proceed (`yesButtonClicked`) | New Task (`clearTask`) |
| `followup` | Answer (`messageResponse`) | - |
| `tool` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `command` | Run (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `browser_action_launch` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `use_mcp_server` | Approve (`yesButtonClicked`) | Reject (`noButtonClicked`) |
| `completion_result` | New Task (`clearTask`) | - |
| `resume_task` | Resume (`yesButtonClicked`) | Terminate (`clearTask`) |
| `resume_completed_task` | New Task (`clearTask`) | - |
| `command_output` | Proceed (`continue`) | Kill (`abort`) |
## Extension-Side Event Emission
The extension emits task state events from [`src/core/task/Task.ts`](../src/core/task/Task.ts):
```
┌─────────────────┐
│ Task Started │
└────────┬────────┘
┌─────────────────┐
┌────▶│ TaskActive │◀────┐
│ └────────┬────────┘ │
│ │ │
│ ┌─────────┼─────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───┐ ┌───────┐ ┌─────┐ │
│ │Idle│ │Interact│ │Resume│ │
│ │Ask │ │iveAsk │ │ableAsk│ │
│ └─┬──┘ └───┬───┘ └──┬──┘ │
│ │ │ │ │
│ ▼ │ │ │
│ ┌──────┐ │ │ │
│ │TaskIdle│ │ │ │
│ └──────┘ │ │ │
│ ▼ │ │
│ ┌───────────────┐ │ │
│ │TaskInteractive│ │ │
│ └───────┬───────┘ │ │
│ │ │ │
│ │ User │ │
│ │ approves│ │
│ │ ▼ │
│ │ ┌───────────┐
│ │ │TaskResumable│
│ │ └─────┬─────┘
│ │ │
│ │ User │
│ │ resumes│
│ │ │
└──────────────┴────────┘
```
The extension uses helper functions to categorize asks and emit the appropriate events:
- `isInteractiveAsk()` → emits `TaskInteractive`
- `isIdleAsk()` → emits `TaskIdle`
- `isResumableAsk()` → emits `TaskResumable`
## WebviewMessage Types for Responses
When responding to asks, use the appropriate `WebviewMessage` type (defined in [`packages/types/src/vscode-extension-host.ts`](../packages/types/src/vscode-extension-host.ts)):
```typescript
interface WebviewMessage {
type:
| "askResponse" // Respond to an ask
| "newTask" // Start a new task
| "clearTask" // Clear/end current task
| "cancelTask" // Cancel running task
| "terminalOperation" // Control terminal output
// ... many other types
askResponse?: ClineAskResponse // "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
text?: string
images?: string[]
terminalOperation?: "continue" | "abort"
}
```
## Summary
To correctly detect when the agent loop has stopped in an alternative client:
1. **Monitor `clineMessages`** from state updates
2. **Check the last message's `type` and `ask`/`say` properties**
3. **Check `partial` flag** to detect streaming
4. **For API request status**, parse the `api_req_started` message's `text` field and check if `cost` is defined
5. **Use the ask category functions** (`isIdleAsk`, `isInteractiveAsk`, etc.) to determine the appropriate UI state
6. **Respond with the correct `askResponse` type** based on user action
The key insight is that the agent loop stops whenever a message with `type: "ask"` arrives, and the specific `ask` value determines what kind of response the agent is waiting for.

View file

@ -3,9 +3,10 @@
# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
#
# Environment variables:
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
# ROO_VERSION - Specific version to install (default: latest)
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
# ROO_VERSION - Specific version to install (default: latest)
# ROO_LOCAL_TARBALL - Path to local tarball to install (skips download)
set -e
@ -83,6 +84,13 @@ detect_platform() {
# Get latest release version or use specified version
get_version() {
# Skip version fetch if using local tarball
if [ -n "$ROO_LOCAL_TARBALL" ]; then
VERSION="${ROO_VERSION:-local}"
info "Using local tarball (version: $VERSION)"
return
fi
if [ -n "$ROO_VERSION" ]; then
VERSION="$ROO_VERSION"
info "Using specified version: $VERSION"
@ -97,10 +105,10 @@ get_version() {
}
# Extract the latest cli-v* tag
VERSION=$(echo "$RELEASES_JSON" |
grep -o '"tag_name": "cli-v[^"]*"' |
head -1 |
sed 's/"tag_name": "cli-v//' |
VERSION=$(echo "$RELEASES_JSON" |
grep -o '"tag_name": "cli-v[^"]*"' |
head -1 |
sed 's/"tag_name": "cli-v//' |
sed 's/"//')
if [ -z "$VERSION" ]; then
@ -113,27 +121,37 @@ get_version() {
# Download and extract
download_and_install() {
TARBALL="roo-cli-${PLATFORM}.tar.gz"
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
info "Downloading from $URL..."
# Create temp directory
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
# Download with progress indicator
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
if [ "$HTTP_CODE" = "404" ]; then
error "Release not found for platform $PLATFORM version $VERSION.
# Use local tarball if provided, otherwise download
if [ -n "$ROO_LOCAL_TARBALL" ]; then
if [ ! -f "$ROO_LOCAL_TARBALL" ]; then
error "Local tarball not found: $ROO_LOCAL_TARBALL"
fi
info "Using local tarball: $ROO_LOCAL_TARBALL"
cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL"
else
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
info "Downloading from $URL..."
# Download with progress indicator
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
if [ "$HTTP_CODE" = "404" ]; then
error "Release not found for platform $PLATFORM version $VERSION.
Available at: https://github.com/$REPO/releases"
fi
error "Download failed. HTTP code: $HTTP_CODE"
}
fi
error "Download failed. HTTP code: $HTTP_CODE"
}
# Verify we got something
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
error "Downloaded file is empty. Please try again."
# Verify we got something
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
error "Downloaded file is empty. Please try again."
fi
fi
# Remove old installation if exists
@ -260,7 +278,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.0.45",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
@ -14,22 +14,34 @@
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"start": "node dist/index.js",
"dev": "tsup --watch",
"start": "ROO_SDK_BASE_URL=http://localhost:3001 ROO_AUTH_BASE_URL=http://localhost:3000 node dist/index.js",
"start:production": "node dist/index.js",
"release": "scripts/release.sh",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@inkjs/ui": "^2.0.0",
"@roo-code/core": "workspace:^",
"@roo-code/types": "workspace:^",
"@roo-code/vscode-shim": "workspace:^",
"@trpc/client": "^11.8.1",
"@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",
"superjson": "^2.2.6",
"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

@ -1,17 +1,22 @@
#!/bin/bash
# Roo Code CLI Release Script
#
#
# Usage:
# ./apps/cli/scripts/release.sh [version]
# ./apps/cli/scripts/release.sh [options] [version]
#
# Options:
# --dry-run Run all steps except creating the GitHub release
#
# Examples:
# ./apps/cli/scripts/release.sh # Use version from package.json
# ./apps/cli/scripts/release.sh 0.1.0 # Specify version
# ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing
# ./apps/cli/scripts/release.sh --dry-run 0.1.0 # Dry run with specific version
#
# This script:
# 1. Builds the extension and CLI
# 2. Creates a tarball for the current platform
# 3. Creates a GitHub release and uploads the tarball
# 3. Creates a GitHub release and uploads the tarball (unless --dry-run)
#
# Prerequisites:
# - GitHub CLI (gh) installed and authenticated
@ -20,6 +25,27 @@
set -e
# Parse arguments
DRY_RUN=false
VERSION_ARG=""
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
-*)
echo "Unknown option: $1" >&2
exit 1
;;
*)
VERSION_ARG="$1"
shift
;;
esac
done
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
@ -60,7 +86,7 @@ detect_platform() {
# Check prerequisites
check_prerequisites() {
step "1/7" "Checking prerequisites..."
step "1/8" "Checking prerequisites..."
if ! command -v gh &> /dev/null; then
error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
@ -83,8 +109,8 @@ check_prerequisites() {
# Get version
get_version() {
if [ -n "$1" ]; then
VERSION="$1"
if [ -n "$VERSION_ARG" ]; then
VERSION="$VERSION_ARG"
else
VERSION=$(node -p "require('$CLI_DIR/package.json').version")
fi
@ -98,13 +124,71 @@ get_version() {
info "Version: $VERSION (tag: $TAG)"
}
# Extract changelog content for a specific version
# Returns the content between the version header and the next version header (or EOF)
get_changelog_content() {
CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md"
if [ ! -f "$CHANGELOG_FILE" ]; then
warn "No CHANGELOG.md found at $CHANGELOG_FILE"
CHANGELOG_CONTENT=""
return
fi
# Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats)
# Also handles "Unreleased" marker
VERSION_PATTERN="^\#\# \[${VERSION}\]"
# Check if the version exists in the changelog
if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then
warn "No changelog entry found for version $VERSION"
warn "Please add an entry to $CHANGELOG_FILE before releasing"
echo ""
echo "Expected format:"
echo " ## [$VERSION] - $(date +%Y-%m-%d)"
echo " "
echo " ### Added"
echo " - Your changes here"
echo ""
read -p "Continue without changelog content? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
error "Aborted. Please add a changelog entry and try again."
fi
CHANGELOG_CONTENT=""
return
fi
# Extract content between this version and the next version header (or EOF)
# Uses awk to capture everything between ## [VERSION] and the next ## [
# Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10)
CHANGELOG_CONTENT=$(awk -v version="$VERSION" '
BEGIN { found = 0; content = ""; target = "[" version "]" }
/^## \[/ {
if (found) { exit }
if (index($0, target) > 0) { found = 1; next }
}
found { content = content $0 "\n" }
END { print content }
' "$CHANGELOG_FILE")
# Trim leading/trailing whitespace
CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
if [ -n "$CHANGELOG_CONTENT" ]; then
info "Found changelog content for version $VERSION"
else
warn "Changelog entry for $VERSION appears to be empty"
fi
}
# Build everything
build() {
step "2/7" "Building extension bundle..."
step "2/8" "Building extension bundle..."
cd "$REPO_ROOT"
pnpm bundle
step "3/7" "Building CLI..."
step "3/8" "Building CLI..."
pnpm --filter @roo-code/cli build
info "Build complete"
@ -112,7 +196,7 @@ build() {
# Create release tarball
create_tarball() {
step "4/7" "Creating release tarball for $PLATFORM..."
step "4/8" "Creating release tarball for $PLATFORM..."
RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}"
TARBALL="roo-cli-${PLATFORM}.tar.gz"
@ -130,7 +214,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 +223,14 @@ create_tarball() {
version: pkg.version,
type: 'module',
dependencies: {
commander: pkg.dependencies.commander
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
'@trpc/client': pkg.dependencies['@trpc/client'],
'commander': pkg.dependencies.commander,
'fuzzysort': pkg.dependencies.fuzzysort,
'ink': pkg.dependencies.ink,
'react': pkg.dependencies.react,
'superjson': pkg.dependencies.superjson,
'zustand': pkg.dependencies.zustand
}
};
console.log(JSON.stringify(newPkg, null, 2));
@ -185,6 +276,8 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Set environment variables for the CLI
// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is)
process.env.ROO_CLI_ROOT = join(__dirname, '..');
process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');
process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');
@ -197,6 +290,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"
@ -211,9 +307,91 @@ WRAPPER_EOF
info "Created: $TARBALL ($TARBALL_SIZE)"
}
# Verify local installation
verify_local_install() {
step "5/8" "Verifying local installation..."
VERIFY_DIR="$REPO_ROOT/.verify-release"
VERIFY_INSTALL_DIR="$VERIFY_DIR/cli"
VERIFY_BIN_DIR="$VERIFY_DIR/bin"
# Clean up any previous verification directory
rm -rf "$VERIFY_DIR"
mkdir -p "$VERIFY_DIR"
# Run the actual install script with the local tarball
info "Running install script with local tarball..."
TARBALL_PATH="$REPO_ROOT/$TARBALL"
ROO_LOCAL_TARBALL="$TARBALL_PATH" \
ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \
ROO_BIN_DIR="$VERIFY_BIN_DIR" \
ROO_VERSION="$VERSION" \
"$CLI_DIR/install.sh" || {
echo ""
warn "Install script failed. Showing tarball contents:"
tar -tzf "$TARBALL_PATH" 2>&1 || true
echo ""
rm -rf "$VERIFY_DIR"
error "Installation verification failed! The install script could not complete successfully."
}
# Verify the CLI runs correctly with basic commands
info "Testing installed CLI..."
# Test --help
if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then
echo ""
warn "CLI --help output:"
"$VERIFY_BIN_DIR/roo" --help 2>&1 || true
echo ""
rm -rf "$VERIFY_DIR"
error "CLI --help check failed! The release tarball may have missing dependencies."
fi
info "CLI --help check passed"
# Test --version
if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then
echo ""
warn "CLI --version output:"
"$VERIFY_BIN_DIR/roo" --version 2>&1 || true
echo ""
rm -rf "$VERIFY_DIR"
error "CLI --version check failed! The release tarball may have missing dependencies."
fi
info "CLI --version check passed"
# Run a simple end-to-end test to verify the CLI actually works
info "Running end-to-end verification test..."
# Create a temporary workspace for the test
VERIFY_WORKSPACE="$VERIFY_DIR/workspace"
mkdir -p "$VERIFY_WORKSPACE"
# Run the CLI with a simple prompt
# Use timeout to prevent hanging if something goes wrong
if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --exit-on-complete --prompt "1+1=?" "$VERIFY_WORKSPACE" > "$VERIFY_DIR/test-output.log" 2>&1; then
info "End-to-end test passed"
else
EXIT_CODE=$?
echo ""
warn "End-to-end test failed (exit code: $EXIT_CODE). Output:"
cat "$VERIFY_DIR/test-output.log" 2>&1 || true
echo ""
rm -rf "$VERIFY_DIR"
error "CLI end-to-end test failed! The CLI may be broken."
fi
# Clean up verification directory
cd "$REPO_ROOT"
rm -rf "$VERIFY_DIR"
info "Local verification passed!"
}
# Create checksum
create_checksum() {
step "5/7" "Creating checksum..."
step "6/8" "Creating checksum..."
cd "$REPO_ROOT"
if command -v sha256sum &> /dev/null; then
@ -230,7 +408,7 @@ create_checksum() {
# Check if release already exists
check_existing_release() {
step "6/7" "Checking for existing release..."
step "7/8" "Checking for existing release..."
if gh release view "$TAG" &> /dev/null; then
warn "Release $TAG already exists"
@ -250,11 +428,45 @@ check_existing_release() {
# Create GitHub release
create_release() {
step "7/7" "Creating GitHub release..."
step "8/8" "Creating GitHub release..."
cd "$REPO_ROOT"
# Get the current commit SHA for the release target
COMMIT_SHA=$(git rev-parse HEAD)
# Verify the commit exists on GitHub before attempting to create the release
# This prevents the "Release.target_commitish is invalid" error
info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..."
git fetch origin 2>/dev/null || true
if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then
warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub"
echo ""
echo "The release script needs to create a release at your current commit,"
echo "but this commit hasn't been pushed to GitHub yet."
echo ""
read -p "Push current branch to origin now? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
info "Pushing to origin..."
git push origin HEAD || error "Failed to push to origin. Please push manually and try again."
else
error "Aborted. Please push your commits to GitHub and try again."
fi
fi
info "Commit verified on GitHub"
# Build the What's New section from changelog content
WHATS_NEW_SECTION=""
if [ -n "$CHANGELOG_CONTENT" ]; then
WHATS_NEW_SECTION="## What's New
$CHANGELOG_CONTENT
"
fi
RELEASE_NOTES=$(cat << EOF
## Installation
${WHATS_NEW_SECTION}## Installation
\`\`\`bash
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
@ -277,7 +489,7 @@ ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo
export OPENROUTER_API_KEY=sk-or-v1-...
# Run a task
roo "What is this project?" --workspace ~/my-project
roo "What is this project?" ~/my-project
# See all options
roo --help
@ -298,8 +510,6 @@ $(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A")
EOF
)
# Get the current commit SHA for the release target
COMMIT_SHA=$(git rev-parse HEAD)
info "Creating release at commit: ${COMMIT_SHA:0:8}"
# Create release (gh will create the tag automatically)
@ -338,6 +548,24 @@ print_summary() {
echo ""
}
# Print dry-run summary
print_dry_run_summary() {
echo ""
printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n"
echo ""
echo " The following artifacts were created:"
echo " - $TARBALL"
if [ -f "${TARBALL}.sha256" ]; then
echo " - ${TARBALL}.sha256"
fi
echo ""
echo " To complete the release, run without --dry-run:"
echo " ./apps/cli/scripts/release.sh $VERSION"
echo ""
echo " Or manually upload the tarball to a new GitHub release."
echo ""
}
# Main
main() {
echo ""
@ -346,18 +574,31 @@ main() {
echo " │ Roo Code CLI Release Script │"
echo " ╰─────────────────────────────────╯"
printf "${NC}"
if [ "$DRY_RUN" = true ]; then
printf "${YELLOW} │ (DRY RUN MODE) │${NC}\n"
fi
echo ""
detect_platform
check_prerequisites
get_version "$1"
get_version
get_changelog_content
build
create_tarball
verify_local_install
create_checksum
check_existing_release
create_release
cleanup
print_summary
if [ "$DRY_RUN" = true ]; then
step "7/8" "Skipping existing release check (dry run)"
step "8/8" "Skipping GitHub release creation (dry run)"
print_dry_run_summary
else
check_existing_release
create_release
cleanup
print_summary
fi
}
main "$@"
main

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,28 @@
/**
* Integration tests for CLI
*
* These tests require a valid OPENROUTER_API_KEY environment variable.
* They will be skipped if the API key is not available.
* These tests require:
* 1. RUN_CLI_INTEGRATION_TESTS=true environment variable (opt-in)
* 2. A valid OPENROUTER_API_KEY environment variable
* 3. A built extension at src/dist
* 4. ripgrep binary available (vscode-ripgrep or system ripgrep)
*
* Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test
* Run with: RUN_CLI_INTEGRATION_TESTS=true OPENROUTER_API_KEY=sk-or-v1-... pnpm test
*/
import { ExtensionHost } from "../extension-host.js"
// pnpm --filter @roo-code/cli test src/__tests__/index.test.ts
import { ExtensionHost } from "../extension-host/extension-host.js"
import path from "path"
import fs from "fs"
import os from "os"
import { execSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const RUN_INTEGRATION_TESTS = process.env.RUN_CLI_INTEGRATION_TESTS === "true"
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
const hasApiKey = !!OPENROUTER_API_KEY
@ -34,8 +41,25 @@ function findExtensionPath(): string | null {
return null
}
// Check if ripgrep is available (required by the extension for file listing)
function hasRipgrep(): boolean {
try {
// Try vscode-ripgrep first (installed as dependency)
const vscodeRipgrepPath = path.resolve(__dirname, "../../../../node_modules/@vscode/ripgrep/bin/rg")
if (fs.existsSync(vscodeRipgrepPath)) {
return true
}
// Try system ripgrep
execSync("rg --version", { stdio: "ignore" })
return true
} catch {
return false
}
}
const extensionPath = findExtensionPath()
const hasExtension = !!extensionPath
const ripgrepAvailable = hasRipgrep()
// Create a temporary workspace directory for tests
function createTempWorkspace(): string {
@ -52,8 +76,8 @@ function cleanupWorkspace(workspacePath: string): void {
}
}
describe.skipIf(!hasApiKey || !hasExtension)(
"CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)",
describe.skipIf(!RUN_INTEGRATION_TESTS || !hasApiKey || !hasExtension || !ripgrepAvailable)(
"CLI Integration Tests (requires RUN_CLI_INTEGRATION_TESTS=true, OPENROUTER_API_KEY, built extension, and ripgrep)",
() => {
let workspacePath: string
let host: ExtensionHost
@ -85,12 +109,12 @@ describe.skipIf(!hasApiKey || !hasExtension)(
it("should complete end-to-end task execution with proper lifecycle", async () => {
host = new ExtensionHost({
mode: "code",
apiProvider: "openrouter",
user: null,
provider: "openrouter",
apiKey: OPENROUTER_API_KEY!,
model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests.
workspacePath,
extensionPath: extensionPath!,
quiet: true,
})
// Test activation
@ -124,9 +148,18 @@ describe.skipIf(!hasApiKey || !hasExtension)(
// Additional test to verify skip behavior
describe("Integration test skip behavior", () => {
it("should require RUN_CLI_INTEGRATION_TESTS=true", () => {
if (RUN_INTEGRATION_TESTS) {
console.log("RUN_CLI_INTEGRATION_TESTS=true, integration tests are enabled")
} else {
console.log("RUN_CLI_INTEGRATION_TESTS is not set to 'true', integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
it("should have OPENROUTER_API_KEY check", () => {
if (hasApiKey) {
console.log("OPENROUTER_API_KEY is set, integration tests will run")
console.log("OPENROUTER_API_KEY is set")
} else {
console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped")
}
@ -141,4 +174,13 @@ describe("Integration test skip behavior", () => {
}
expect(true).toBe(true) // Always passes
})
it("should have ripgrep check", () => {
if (ripgrepAvailable) {
console.log("ripgrep is available")
} else {
console.log("ripgrep not found, integration tests will be skipped")
}
expect(true).toBe(true) // Always passes
})
})

View file

@ -0,0 +1,3 @@
export * from "./login.js"
export * from "./logout.js"
export * from "./status.js"

View file

@ -0,0 +1,186 @@
import http from "http"
import { randomBytes } from "crypto"
import net from "net"
import { exec } from "child_process"
import { AUTH_BASE_URL } from "../../types/constants.js"
import { saveToken } from "../../lib/storage/credentials.js"
export interface LoginOptions {
timeout?: number
verbose?: boolean
}
export interface LoginResult {
success: boolean
error?: string
userId?: string
orgId?: string | null
}
const LOCALHOST = "127.0.0.1"
export async function login({ timeout = 5 * 60 * 1000, verbose = false }: LoginOptions = {}): Promise<LoginResult> {
const state = randomBytes(16).toString("hex")
const port = await getAvailablePort()
const host = `http://${LOCALHOST}:${port}`
if (verbose) {
console.log(`[Auth] Starting local callback server on port ${port}`)
}
// Create promise that will be resolved when we receive the callback.
const tokenPromise = new Promise<{ token: string; state: string }>((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, host)
if (url.pathname === "/callback") {
const receivedState = url.searchParams.get("state")
const token = url.searchParams.get("token")
const error = url.searchParams.get("error")
if (error) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=error-in-callback`)
errorUrl.searchParams.set("message", error)
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
// Wait for response to be fully sent before closing server and rejecting.
// The 'close' event fires when the underlying connection is terminated,
// ensuring the browser has received the redirect before we shut down.
res.on("close", () => {
server.close()
reject(new Error(error))
})
} else if (!token) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=missing-token`)
errorUrl.searchParams.set("message", "Missing token in callback")
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
res.on("close", () => {
server.close()
reject(new Error("Missing token in callback"))
})
} else if (receivedState !== state) {
const errorUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in?error=invalid-state-parameter`)
errorUrl.searchParams.set("message", "Invalid state parameter (possible CSRF attack)")
res.writeHead(302, { Location: errorUrl.toString() })
res.end()
res.on("close", () => {
server.close()
reject(new Error("Invalid state parameter"))
})
} else {
res.writeHead(302, { Location: `${AUTH_BASE_URL}/cli/sign-in?success=true` })
res.end()
res.on("close", () => {
server.close()
resolve({ token, state: receivedState })
})
}
} else {
res.writeHead(404, { "Content-Type": "text/plain" })
res.end("Not found")
}
})
server.listen(port, LOCALHOST)
const timeoutId = setTimeout(() => {
server.close()
reject(new Error("Authentication timed out"))
}, timeout)
server.on("listening", () => {
console.log(`[Auth] Callback server listening on port ${port}`)
})
server.on("close", () => {
console.log("[Auth] Callback server closed")
clearTimeout(timeoutId)
})
})
const authUrl = new URL(`${AUTH_BASE_URL}/cli/sign-in`)
authUrl.searchParams.set("state", state)
authUrl.searchParams.set("callback", `${host}/callback`)
console.log("Opening browser for authentication...")
console.log(`If the browser doesn't open, visit: ${authUrl.toString()}`)
try {
await openBrowser(authUrl.toString())
} catch (error) {
if (verbose) {
console.warn("[Auth] Failed to open browser automatically:", error)
}
console.log("Please open the URL above in your browser manually.")
}
try {
const { token } = await tokenPromise
await saveToken(token)
console.log("✓ Successfully authenticated!")
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`✗ Authentication failed: ${message}`)
return { success: false, error: message }
}
}
async function getAvailablePort(startPort = 49152, endPort = 65535): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer()
let port = startPort
const tryPort = () => {
server.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE" && port < endPort) {
port++
tryPort()
} else {
reject(err)
}
})
server.once("listening", () => {
server.close(() => {
resolve(port)
})
})
server.listen(port, LOCALHOST)
}
tryPort()
})
}
function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const platform = process.platform
let command: string
switch (platform) {
case "darwin":
command = `open "${url}"`
break
case "win32":
command = `start "" "${url}"`
break
default:
// Linux and other Unix-like systems.
command = `xdg-open "${url}"`
break
}
exec(command, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}

View file

@ -0,0 +1,27 @@
import { clearToken, hasToken, getCredentialsPath } from "../../lib/storage/credentials.js"
export interface LogoutOptions {
verbose?: boolean
}
export interface LogoutResult {
success: boolean
wasLoggedIn: boolean
}
export async function logout({ verbose = false }: LogoutOptions = {}): Promise<LogoutResult> {
const wasLoggedIn = await hasToken()
if (!wasLoggedIn) {
console.log("You are not currently logged in.")
return { success: true, wasLoggedIn: false }
}
if (verbose) {
console.log(`[Auth] Removing credentials from ${getCredentialsPath()}`)
}
await clearToken()
console.log("✓ Successfully logged out")
return { success: true, wasLoggedIn: true }
}

View file

@ -0,0 +1,97 @@
import { loadToken, loadCredentials, getCredentialsPath } from "../../lib/storage/index.js"
import { isTokenExpired, isTokenValid, getTokenExpirationDate } from "../../lib/auth/index.js"
export interface StatusOptions {
verbose?: boolean
}
export interface StatusResult {
authenticated: boolean
expired?: boolean
expiringSoon?: boolean
userId?: string
orgId?: string | null
expiresAt?: Date
createdAt?: Date
}
export async function status(options: StatusOptions = {}): Promise<StatusResult> {
const { verbose = false } = options
const token = await loadToken()
if (!token) {
console.log("✗ Not authenticated")
console.log("")
console.log("Run: roo auth login")
return { authenticated: false }
}
const expiresAt = getTokenExpirationDate(token)
const expired = !isTokenValid(token)
const expiringSoon = isTokenExpired(token, 24 * 60 * 60) && !expired
const credentials = await loadCredentials()
const createdAt = credentials?.createdAt ? new Date(credentials.createdAt) : undefined
if (expired) {
console.log("✗ Authentication token expired")
console.log("")
console.log("Run: roo auth login")
return {
authenticated: false,
expired: true,
expiresAt: expiresAt ?? undefined,
}
}
if (expiringSoon) {
console.log("⚠ Expires soon; refresh with `roo auth login`")
} else {
console.log("✓ Authenticated")
}
if (expiresAt) {
const remaining = getTimeRemaining(expiresAt)
console.log(` Expires: ${formatDate(expiresAt)} (${remaining})`)
}
if (createdAt && verbose) {
console.log(` Created: ${formatDate(createdAt)}`)
}
if (verbose) {
console.log(` Credentials: ${getCredentialsPath()}`)
}
return {
authenticated: true,
expired: false,
expiringSoon,
expiresAt: expiresAt ?? undefined,
createdAt,
}
}
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
}
function getTimeRemaining(date: Date): string {
const now = new Date()
const diff = date.getTime() - now.getTime()
if (diff <= 0) {
return "expired"
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (days > 0) {
return `${days} day${days === 1 ? "" : "s"}`
}
return `${hours} hour${hours === 1 ? "" : "s"}`
}

View file

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

View file

@ -0,0 +1,210 @@
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createElement } from "react"
import { isProviderName } from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { FlagOptions, isSupportedProvider, OnboardingProviderChoice, supportedProviders } from "../../types/types.js"
import { ASCII_ROO, DEFAULT_FLAGS, REASONING_EFFORTS, SDK_BASE_URL } from "../../types/constants.js"
import { ExtensionHost, ExtensionHostOptions } from "../../extension-host/index.js"
import { type User, createClient } from "../../lib/sdk/index.js"
import { loadToken, hasToken, loadSettings } from "../../lib/storage/index.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../../extension-host/utils.js"
import { runOnboarding } from "../../lib/utils/onboarding.js"
import { VERSION } from "../../lib/utils/version.js"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export async function run(workspaceArg: string, options: FlagOptions) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
const isTuiSupported = process.stdin.isTTY && process.stdout.isTTY
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const workspacePath = path.resolve(workspaceArg)
if (!isSupportedProvider(options.provider)) {
console.error(
`[CLI] Error: Invalid provider: ${options.provider}; must be one of: ${supportedProviders.join(", ")}`,
)
process.exit(1)
}
let apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
let provider = options.provider
let user: User | null = null
let useCloudProvider = false
if (isTuiSupported) {
let { onboardingProviderChoice } = await loadSettings()
if (!onboardingProviderChoice) {
const result = await runOnboarding()
onboardingProviderChoice = result.choice
}
if (onboardingProviderChoice === OnboardingProviderChoice.Roo) {
useCloudProvider = true
const authenticated = await hasToken()
if (authenticated) {
const token = await loadToken()
if (token) {
try {
const client = createClient({ url: SDK_BASE_URL, authToken: token })
const me = await client.auth.me.query()
provider = "roo"
apiKey = token
user = me?.type === "user" ? me.user : null
} catch {
// Token may be expired or invalid - user will need to re-authenticate
}
}
}
}
}
if (!apiKey) {
if (useCloudProvider) {
console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.")
console.error("[CLI] Please run: roo auth login")
console.error("[CLI] Or use --api-key to provide your own API key.")
} else {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(`[CLI] For ${provider}, set ${getEnvVarName(provider)}`)
}
process.exit(1)
}
if (!fs.existsSync(workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
process.exit(1)
}
if (!isProviderName(options.provider)) {
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
process.exit(1)
}
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
process.exit(1)
}
const useTui = options.tui && isTuiSupported
if (options.tui && !isTuiSupported) {
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
}
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) {
try {
const { render } = await import("ink")
const { App } = await import("../../ui/App.js")
render(
createElement(App, {
initialPrompt: options.prompt || "",
workspacePath: workspacePath,
extensionPath: path.resolve(extensionPath),
user,
provider,
apiKey,
model: options.model || DEFAULT_FLAGS.model,
mode: options.mode || DEFAULT_FLAGS.mode,
nonInteractive: options.yes,
debug: options.debug,
exitOnComplete: options.exitOnComplete,
reasoningEffort: options.reasoningEffort,
ephemeral: options.ephemeral,
version: VERSION,
// Create extension host factory for dependency injection.
createExtensionHost: (opts: ExtensionHostOptions) => new ExtensionHost(opts),
}),
// Handle Ctrl+C in App component for double-press exit.
{ exitOnCtrlC: false },
)
} catch (error) {
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
if (error instanceof Error) {
console.error(error.stack)
}
process.exit(1)
}
} else {
console.log(ASCII_ROO)
console.log()
console.log(
`[roo] Running ${options.model || "default"} (${options.reasoningEffort || "default"}) on ${provider} in ${options.mode || "default"} mode in ${workspacePath}`,
)
const host = new ExtensionHost({
mode: options.mode || DEFAULT_FLAGS.mode,
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
user,
provider,
apiKey,
model: options.model || DEFAULT_FLAGS.model,
workspacePath,
extensionPath: path.resolve(extensionPath),
nonInteractive: options.yes,
ephemeral: options.ephemeral,
debug: options.debug,
})
process.on("SIGINT", async () => {
console.log("\n[CLI] Received SIGINT, shutting down...")
await host.dispose()
process.exit(130)
})
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!)
await host.dispose()
if (!options.waitOnComplete) {
process.exit(0)
}
} catch (error) {
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
if (error instanceof Error) {
console.error(error.stack)
}
await host.dispose()
process.exit(1)
}
}
}

View file

@ -0,0 +1,2 @@
export * from "./auth/index.js"
export * from "./cli/index.js"

View file

@ -0,0 +1,453 @@
/**
* Agent Loop State Detection
*
* This module provides the core logic for detecting the current state of the
* Roo Code agent loop. The state is determined by analyzing the clineMessages
* array, specifically the last message's type and properties.
*
* Key insight: The agent loop stops whenever a message with `type: "ask"` arrives,
* and the specific `ask` value determines what kind of response the agent is waiting for.
*/
import type { ClineMessage, ClineAsk, ApiReqStartedText } from "./types.js"
import { isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk } from "./types.js"
// Re-export the type guards for convenience
export { isIdleAsk, isResumableAsk, isInteractiveAsk, isNonBlockingAsk }
// =============================================================================
// Agent Loop State Enum
// =============================================================================
/**
* The possible states of the agent loop.
*
* State Machine:
* ```
*
* NO_TASK (initial state)
*
* newTask
*
*
* RUNNING
*
*
*
*
*
*
* STREAM INTERACT IDLE
* ING IVE
*
*
* done approved newTask
*
*
*
* RESUMABLE
* resumed
* ```
*/
export enum AgentLoopState {
/**
* No active task. This is the initial state before any task is started,
* or after a task has been cleared.
*/
NO_TASK = "no_task",
/**
* Agent is actively processing. This means:
* - The last message is a "say" type (informational), OR
* - The last message is a non-blocking ask (command_output)
*
* In this state, the agent may be:
* - Executing tools
* - Thinking/reasoning
* - Processing between API calls
*/
RUNNING = "running",
/**
* Agent is streaming a response. This is detected when:
* - `partial === true` on the last message, OR
* - The last `api_req_started` message has no `cost` in its text field
*
* Do NOT consider the agent "waiting" while streaming.
*/
STREAMING = "streaming",
/**
* Agent is waiting for user approval or input. This includes:
* - Tool approvals (file operations)
* - Command execution permission
* - Browser action permission
* - MCP server permission
* - Follow-up questions
*
* User must approve, reject, or provide input to continue.
*/
WAITING_FOR_INPUT = "waiting_for_input",
/**
* Task is in an idle/terminal state. This includes:
* - Task completed successfully (completion_result)
* - API request failed (api_req_failed)
* - Too many errors (mistake_limit_reached)
* - Auto-approval limit reached
* - Completed task waiting to be resumed
*
* User can start a new task or retry.
*/
IDLE = "idle",
/**
* Task is paused and can be resumed. This happens when:
* - User navigated away from a task
* - Extension was restarted mid-task
*
* User can resume or abandon the task.
*/
RESUMABLE = "resumable",
}
// =============================================================================
// Detailed State Info
// =============================================================================
/**
* What action the user should/can take in the current state.
*/
export type RequiredAction =
| "none" // No action needed (running/streaming)
| "approve" // Can approve/reject (tool, command, browser, mcp)
| "answer" // Need to answer a question (followup)
| "retry_or_new_task" // Can retry or start new task (api_req_failed)
| "proceed_or_new_task" // Can proceed or start new task (mistake_limit)
| "start_task" // Should start a new task (completion_result)
| "resume_or_abandon" // Can resume or abandon (resume_task)
| "start_new_task" // Should start new task (resume_completed_task, no_task)
| "continue_or_abort" // Can continue or abort (command_output)
/**
* Detailed information about the current agent state.
* Provides everything needed to render UI or make decisions.
*/
export interface AgentStateInfo {
/** The high-level state of the agent loop */
state: AgentLoopState
/** Whether the agent is waiting for user input/action */
isWaitingForInput: boolean
/** Whether the agent loop is actively processing */
isRunning: boolean
/** Whether content is being streamed */
isStreaming: boolean
/** The specific ask type if waiting on an ask, undefined otherwise */
currentAsk?: ClineAsk
/** What action the user should/can take */
requiredAction: RequiredAction
/** The timestamp of the last message, useful for tracking */
lastMessageTs?: number
/** The full last message for advanced usage */
lastMessage?: ClineMessage
/** Human-readable description of the current state */
description: string
}
// =============================================================================
// State Detection Functions
// =============================================================================
/**
* Check if an API request is still in progress (streaming).
*
* API requests are considered in-progress when:
* - An api_req_started message exists
* - Its text field, when parsed, has `cost: undefined`
*
* Once the request completes, the cost field will be populated.
*/
function isApiRequestInProgress(messages: ClineMessage[]): boolean {
// Find the last api_req_started message
// Using reverse iteration for efficiency (most recent first)
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!message) continue
if (message.say === "api_req_started") {
if (!message.text) {
// No text yet means still in progress
return true
}
try {
const data: ApiReqStartedText = JSON.parse(message.text)
// cost is undefined while streaming, defined when complete
return data.cost === undefined
} catch {
// Parse error - assume not in progress
return false
}
}
}
return false
}
/**
* Determine the required action based on the current ask type.
*/
function getRequiredAction(ask: ClineAsk): RequiredAction {
switch (ask) {
case "followup":
return "answer"
case "command":
case "tool":
case "browser_action_launch":
case "use_mcp_server":
return "approve"
case "command_output":
return "continue_or_abort"
case "api_req_failed":
return "retry_or_new_task"
case "mistake_limit_reached":
return "proceed_or_new_task"
case "completion_result":
return "start_task"
case "resume_task":
return "resume_or_abandon"
case "resume_completed_task":
case "auto_approval_max_req_reached":
return "start_new_task"
default:
return "none"
}
}
/**
* Get a human-readable description for the current state.
*/
function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string {
switch (state) {
case AgentLoopState.NO_TASK:
return "No active task. Ready to start a new task."
case AgentLoopState.RUNNING:
return "Agent is actively processing."
case AgentLoopState.STREAMING:
return "Agent is streaming a response."
case AgentLoopState.WAITING_FOR_INPUT:
switch (ask) {
case "followup":
return "Agent is asking a follow-up question. Please provide an answer."
case "command":
return "Agent wants to execute a command. Approve or reject."
case "tool":
return "Agent wants to perform a file operation. Approve or reject."
case "browser_action_launch":
return "Agent wants to use the browser. Approve or reject."
case "use_mcp_server":
return "Agent wants to use an MCP server. Approve or reject."
default:
return "Agent is waiting for user input."
}
case AgentLoopState.IDLE:
switch (ask) {
case "completion_result":
return "Task completed successfully. You can provide feedback or start a new task."
case "api_req_failed":
return "API request failed. You can retry or start a new task."
case "mistake_limit_reached":
return "Too many errors encountered. You can proceed anyway or start a new task."
case "auto_approval_max_req_reached":
return "Auto-approval limit reached. Manual approval required."
case "resume_completed_task":
return "Previously completed task. Start a new task to continue."
default:
return "Task is idle."
}
case AgentLoopState.RESUMABLE:
return "Task is paused. You can resume or start a new task."
default:
return "Unknown state."
}
}
/**
* Detect the current state of the agent loop from the clineMessages array.
*
* This is the main state detection function. It analyzes the messages array
* and returns detailed information about the current agent state.
*
* @param messages - The clineMessages array from extension state
* @returns Detailed state information
*/
export function detectAgentState(messages: ClineMessage[]): AgentStateInfo {
// No messages means no task
if (!messages || messages.length === 0) {
return {
state: AgentLoopState.NO_TASK,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_new_task",
description: getStateDescription(AgentLoopState.NO_TASK),
}
}
const lastMessage = messages[messages.length - 1]
// Guard against undefined (should never happen after length check, but TypeScript requires it)
if (!lastMessage) {
return {
state: AgentLoopState.NO_TASK,
isWaitingForInput: false,
isRunning: false,
isStreaming: false,
requiredAction: "start_new_task",
description: getStateDescription(AgentLoopState.NO_TASK),
}
}
// Check if the message is still streaming (partial)
// This is the PRIMARY indicator of streaming
if (lastMessage.partial === true) {
return {
state: AgentLoopState.STREAMING,
isWaitingForInput: false,
isRunning: true,
isStreaming: true,
currentAsk: lastMessage.ask,
requiredAction: "none",
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.STREAMING),
}
}
// Handle "ask" type messages
if (lastMessage.type === "ask" && lastMessage.ask) {
const ask = lastMessage.ask
// Non-blocking asks (command_output) - agent is running but can be interrupted
if (isNonBlockingAsk(ask)) {
return {
state: AgentLoopState.RUNNING,
isWaitingForInput: false,
isRunning: true,
isStreaming: false,
currentAsk: ask,
requiredAction: "continue_or_abort",
lastMessageTs: lastMessage.ts,
lastMessage,
description: "Command is running. You can continue or abort.",
}
}
// Idle asks - task has stopped
if (isIdleAsk(ask)) {
return {
state: AgentLoopState.IDLE,
isWaitingForInput: true, // User needs to decide what to do next
isRunning: false,
isStreaming: false,
currentAsk: ask,
requiredAction: getRequiredAction(ask),
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.IDLE, ask),
}
}
// Resumable asks - task is paused
if (isResumableAsk(ask)) {
return {
state: AgentLoopState.RESUMABLE,
isWaitingForInput: true,
isRunning: false,
isStreaming: false,
currentAsk: ask,
requiredAction: getRequiredAction(ask),
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.RESUMABLE, ask),
}
}
// Interactive asks - waiting for approval/input
if (isInteractiveAsk(ask)) {
return {
state: AgentLoopState.WAITING_FOR_INPUT,
isWaitingForInput: true,
isRunning: false,
isStreaming: false,
currentAsk: ask,
requiredAction: getRequiredAction(ask),
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.WAITING_FOR_INPUT, ask),
}
}
}
// For "say" type messages, check if API request is in progress
if (isApiRequestInProgress(messages)) {
return {
state: AgentLoopState.STREAMING,
isWaitingForInput: false,
isRunning: true,
isStreaming: true,
requiredAction: "none",
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.STREAMING),
}
}
// Default: agent is running
return {
state: AgentLoopState.RUNNING,
isWaitingForInput: false,
isRunning: true,
isStreaming: false,
requiredAction: "none",
lastMessageTs: lastMessage.ts,
lastMessage,
description: getStateDescription(AgentLoopState.RUNNING),
}
}
/**
* Quick check: Is the agent waiting for user input?
*
* This is a convenience function for simple use cases where you just need
* to know if user action is required.
*/
export function isAgentWaitingForInput(messages: ClineMessage[]): boolean {
return detectAgentState(messages).isWaitingForInput
}
/**
* Quick check: Is the agent actively running (not waiting)?
*/
export function isAgentRunning(messages: ClineMessage[]): boolean {
const state = detectAgentState(messages)
return state.isRunning && !state.isWaitingForInput
}
/**
* Quick check: Is content currently streaming?
*/
export function isContentStreaming(messages: ClineMessage[]): boolean {
return detectAgentState(messages).isStreaming
}

View file

@ -0,0 +1,809 @@
/**
* Tests for the Roo Code Client
*
* These tests verify:
* - State detection logic
* - Event emission
* - Response sending
* - State transitions
*/
import {
type ClineMessage,
type ExtensionMessage,
createMockClient,
AgentLoopState,
detectAgentState,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
} from "./index.js"
// =============================================================================
// Test Helpers
// =============================================================================
function createMessage(overrides: Partial<ClineMessage>): ClineMessage {
return {
ts: Date.now() + Math.random() * 1000, // Unique timestamp
type: "say",
...overrides,
}
}
function createStateMessage(messages: ClineMessage[]): ExtensionMessage {
return {
type: "state",
state: {
clineMessages: messages,
},
}
}
// =============================================================================
// State Detection Tests
// =============================================================================
describe("detectAgentState", () => {
describe("NO_TASK state", () => {
it("should return NO_TASK for empty messages array", () => {
const state = detectAgentState([])
expect(state.state).toBe(AgentLoopState.NO_TASK)
expect(state.isWaitingForInput).toBe(false)
expect(state.isRunning).toBe(false)
})
it("should return NO_TASK for undefined messages", () => {
const state = detectAgentState(undefined as unknown as ClineMessage[])
expect(state.state).toBe(AgentLoopState.NO_TASK)
})
})
describe("STREAMING state", () => {
it("should detect streaming when partial is true", () => {
const messages = [createMessage({ type: "ask", ask: "tool", partial: true })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
expect(state.isWaitingForInput).toBe(false)
})
it("should detect streaming when api_req_started has no cost", () => {
const messages = [
createMessage({
say: "api_req_started",
text: JSON.stringify({ tokensIn: 100 }), // No cost field
}),
]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
it("should NOT be streaming when api_req_started has cost", () => {
const messages = [
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001, tokensIn: 100 }),
}),
]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
})
describe("WAITING_FOR_INPUT state", () => {
it("should detect waiting for tool approval", () => {
const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.isWaitingForInput).toBe(true)
expect(state.currentAsk).toBe("tool")
expect(state.requiredAction).toBe("approve")
})
it("should detect waiting for command approval", () => {
const messages = [createMessage({ type: "ask", ask: "command", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("command")
expect(state.requiredAction).toBe("approve")
})
it("should detect waiting for followup answer", () => {
const messages = [createMessage({ type: "ask", ask: "followup", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("followup")
expect(state.requiredAction).toBe("answer")
})
it("should detect waiting for browser_action_launch approval", () => {
const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.requiredAction).toBe("approve")
})
it("should detect waiting for use_mcp_server approval", () => {
const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.requiredAction).toBe("approve")
})
})
describe("IDLE state", () => {
it("should detect completion_result as idle", () => {
const messages = [createMessage({ type: "ask", ask: "completion_result", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.isWaitingForInput).toBe(true)
expect(state.requiredAction).toBe("start_task")
})
it("should detect api_req_failed as idle", () => {
const messages = [createMessage({ type: "ask", ask: "api_req_failed", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.requiredAction).toBe("retry_or_new_task")
})
it("should detect mistake_limit_reached as idle", () => {
const messages = [createMessage({ type: "ask", ask: "mistake_limit_reached", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.requiredAction).toBe("proceed_or_new_task")
})
it("should detect auto_approval_max_req_reached as idle", () => {
const messages = [createMessage({ type: "ask", ask: "auto_approval_max_req_reached", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.requiredAction).toBe("start_new_task")
})
it("should detect resume_completed_task as idle", () => {
const messages = [createMessage({ type: "ask", ask: "resume_completed_task", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.requiredAction).toBe("start_new_task")
})
})
describe("RESUMABLE state", () => {
it("should detect resume_task as resumable", () => {
const messages = [createMessage({ type: "ask", ask: "resume_task", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RESUMABLE)
expect(state.isWaitingForInput).toBe(true)
expect(state.requiredAction).toBe("resume_or_abandon")
})
})
describe("RUNNING state", () => {
it("should detect running for say messages", () => {
const messages = [
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001 }),
}),
createMessage({ say: "text", text: "Working on it..." }),
]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isRunning).toBe(true)
expect(state.isWaitingForInput).toBe(false)
})
it("should detect running for command_output (non-blocking)", () => {
const messages = [createMessage({ type: "ask", ask: "command_output", partial: false })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.requiredAction).toBe("continue_or_abort")
})
})
})
// =============================================================================
// Type Guard Tests
// =============================================================================
describe("Type Guards", () => {
describe("isIdleAsk", () => {
it("should return true for idle asks", () => {
expect(isIdleAsk("completion_result")).toBe(true)
expect(isIdleAsk("api_req_failed")).toBe(true)
expect(isIdleAsk("mistake_limit_reached")).toBe(true)
expect(isIdleAsk("auto_approval_max_req_reached")).toBe(true)
expect(isIdleAsk("resume_completed_task")).toBe(true)
})
it("should return false for non-idle asks", () => {
expect(isIdleAsk("tool")).toBe(false)
expect(isIdleAsk("followup")).toBe(false)
expect(isIdleAsk("resume_task")).toBe(false)
})
})
describe("isInteractiveAsk", () => {
it("should return true for interactive asks", () => {
expect(isInteractiveAsk("tool")).toBe(true)
expect(isInteractiveAsk("command")).toBe(true)
expect(isInteractiveAsk("followup")).toBe(true)
expect(isInteractiveAsk("browser_action_launch")).toBe(true)
expect(isInteractiveAsk("use_mcp_server")).toBe(true)
})
it("should return false for non-interactive asks", () => {
expect(isInteractiveAsk("completion_result")).toBe(false)
expect(isInteractiveAsk("command_output")).toBe(false)
})
})
describe("isResumableAsk", () => {
it("should return true for resumable asks", () => {
expect(isResumableAsk("resume_task")).toBe(true)
})
it("should return false for non-resumable asks", () => {
expect(isResumableAsk("completion_result")).toBe(false)
expect(isResumableAsk("tool")).toBe(false)
})
})
describe("isNonBlockingAsk", () => {
it("should return true for non-blocking asks", () => {
expect(isNonBlockingAsk("command_output")).toBe(true)
})
it("should return false for blocking asks", () => {
expect(isNonBlockingAsk("tool")).toBe(false)
expect(isNonBlockingAsk("followup")).toBe(false)
})
})
})
// =============================================================================
// ExtensionClient Tests
// =============================================================================
describe("ExtensionClient", () => {
describe("State queries", () => {
it("should return NO_TASK when not initialized", () => {
const { client } = createMockClient()
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
expect(client.isInitialized()).toBe(false)
})
it("should update state when receiving messages", () => {
const { client } = createMockClient()
const message = createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })])
client.handleMessage(message)
expect(client.isInitialized()).toBe(true)
expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(client.isWaitingForInput()).toBe(true)
expect(client.getCurrentAsk()).toBe("tool")
})
})
describe("Event emission", () => {
it("should emit stateChange events", () => {
const { client } = createMockClient()
const stateChanges: AgentLoopState[] = []
client.onStateChange((event) => {
stateChanges.push(event.currentState.state)
})
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
expect(stateChanges).toContain(AgentLoopState.WAITING_FOR_INPUT)
})
it("should emit waitingForInput events", () => {
const { client } = createMockClient()
const waitingEvents: string[] = []
client.onWaitingForInput((event) => {
waitingEvents.push(event.ask)
})
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "followup", partial: false })]))
expect(waitingEvents).toContain("followup")
})
it("should allow unsubscribing from events", () => {
const { client } = createMockClient()
let callCount = 0
const unsubscribe = client.onStateChange(() => {
callCount++
})
client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
expect(callCount).toBe(1)
unsubscribe()
client.handleMessage(createStateMessage([createMessage({ say: "text", ts: Date.now() + 1 })]))
expect(callCount).toBe(1) // Should not increase
})
})
describe("Response methods", () => {
it("should send approve response", () => {
const { client, sentMessages } = createMockClient()
client.approve()
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "askResponse",
askResponse: "yesButtonClicked",
text: undefined,
images: undefined,
})
})
it("should send reject response", () => {
const { client, sentMessages } = createMockClient()
client.reject()
expect(sentMessages).toHaveLength(1)
const msg = sentMessages[0]
expect(msg).toBeDefined()
expect(msg?.askResponse).toBe("noButtonClicked")
})
it("should send text response", () => {
const { client, sentMessages } = createMockClient()
client.respond("My answer", ["image-data"])
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "askResponse",
askResponse: "messageResponse",
text: "My answer",
images: ["image-data"],
})
})
it("should send newTask message", () => {
const { client, sentMessages } = createMockClient()
client.newTask("Build a web app")
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "newTask",
text: "Build a web app",
images: undefined,
})
})
it("should send clearTask message", () => {
const { client, sentMessages } = createMockClient()
client.clearTask()
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "clearTask",
})
})
it("should send cancelTask message", () => {
const { client, sentMessages } = createMockClient()
client.cancelTask()
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "cancelTask",
})
})
it("should send terminal continue operation", () => {
const { client, sentMessages } = createMockClient()
client.continueTerminal()
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "terminalOperation",
terminalOperation: "continue",
})
})
it("should send terminal abort operation", () => {
const { client, sentMessages } = createMockClient()
client.abortTerminal()
expect(sentMessages).toHaveLength(1)
expect(sentMessages[0]).toEqual({
type: "terminalOperation",
terminalOperation: "abort",
})
})
})
describe("Message handling", () => {
it("should handle JSON string messages", () => {
const { client } = createMockClient()
const message = JSON.stringify(
createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
)
client.handleMessage(message)
expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
})
it("should ignore invalid JSON", () => {
const { client } = createMockClient()
client.handleMessage("not valid json")
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
})
it("should handle messageUpdated messages", () => {
const { client } = createMockClient()
// First, set initial state
client.handleMessage(
createStateMessage([createMessage({ ts: 123, type: "ask", ask: "tool", partial: true })]),
)
expect(client.isStreaming()).toBe(true)
// Now update the message
client.handleMessage({
type: "messageUpdated",
clineMessage: createMessage({ ts: 123, type: "ask", ask: "tool", partial: false }),
})
expect(client.isStreaming()).toBe(false)
expect(client.isWaitingForInput()).toBe(true)
})
})
describe("Reset functionality", () => {
it("should reset state", () => {
const { client } = createMockClient()
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
expect(client.isInitialized()).toBe(true)
expect(client.getCurrentState()).toBe(AgentLoopState.WAITING_FOR_INPUT)
client.reset()
expect(client.isInitialized()).toBe(false)
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
})
})
})
// =============================================================================
// Integration Tests
// =============================================================================
describe("Integration", () => {
it("should handle a complete task flow", () => {
const { client } = createMockClient()
const states: AgentLoopState[] = []
client.onStateChange((event) => {
states.push(event.currentState.state)
})
// 1. Task starts, API request begins
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({}), // No cost = streaming
}),
]),
)
expect(client.isStreaming()).toBe(true)
// 2. API request completes
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001 }),
}),
createMessage({ say: "text", text: "I'll help you with that." }),
]),
)
expect(client.isStreaming()).toBe(false)
expect(client.isRunning()).toBe(true)
// 3. Tool ask (partial)
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001 }),
}),
createMessage({ say: "text", text: "I'll help you with that." }),
createMessage({ type: "ask", ask: "tool", partial: true }),
]),
)
expect(client.isStreaming()).toBe(true)
// 4. Tool ask (complete)
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001 }),
}),
createMessage({ say: "text", text: "I'll help you with that." }),
createMessage({ type: "ask", ask: "tool", partial: false }),
]),
)
expect(client.isWaitingForInput()).toBe(true)
expect(client.getCurrentAsk()).toBe("tool")
// 5. User approves, task completes
client.handleMessage(
createStateMessage([
createMessage({
say: "api_req_started",
text: JSON.stringify({ cost: 0.001 }),
}),
createMessage({ say: "text", text: "I'll help you with that." }),
createMessage({ type: "ask", ask: "tool", partial: false }),
createMessage({ say: "text", text: "File created." }),
createMessage({ type: "ask", ask: "completion_result", partial: false }),
]),
)
expect(client.getCurrentState()).toBe(AgentLoopState.IDLE)
expect(client.getCurrentAsk()).toBe("completion_result")
// Verify we saw the expected state transitions
expect(states).toContain(AgentLoopState.STREAMING)
expect(states).toContain(AgentLoopState.RUNNING)
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
expect(states).toContain(AgentLoopState.IDLE)
})
})
// =============================================================================
// Edge Case Tests
// =============================================================================
describe("Edge Cases", () => {
describe("Messages with missing or empty text field", () => {
it("should handle ask message with missing text field", () => {
const messages = [createMessage({ type: "ask", ask: "tool", partial: false })]
// text is undefined by default
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("tool")
})
it("should handle ask message with empty text field", () => {
const messages = [createMessage({ type: "ask", ask: "followup", partial: false, text: "" })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("followup")
})
it("should handle say message with missing text field", () => {
const messages = [createMessage({ say: "text" })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
})
})
describe("api_req_started edge cases", () => {
it("should handle api_req_started with empty text field as streaming", () => {
const messages = [createMessage({ say: "api_req_started", text: "" })]
const state = detectAgentState(messages)
// Empty text is treated as "no text yet" = still in progress (streaming)
// This matches the behavior: !message.text is true for "" (falsy)
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
it("should handle api_req_started with invalid JSON", () => {
const messages = [createMessage({ say: "api_req_started", text: "not valid json" })]
const state = detectAgentState(messages)
// Invalid JSON should not crash, should return not streaming
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
it("should handle api_req_started with null text", () => {
const messages = [createMessage({ say: "api_req_started", text: undefined })]
const state = detectAgentState(messages)
// No text means still in progress (streaming)
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
it("should handle api_req_started with cost of 0", () => {
const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: 0 }) })]
const state = detectAgentState(messages)
// cost: 0 is defined (not undefined), so NOT streaming
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
it("should handle api_req_started with cost of null", () => {
const messages = [createMessage({ say: "api_req_started", text: JSON.stringify({ cost: null }) })]
const state = detectAgentState(messages)
// cost: null is defined (not undefined), so NOT streaming
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.isStreaming).toBe(false)
})
it("should find api_req_started when it's not the last message", () => {
const messages = [
createMessage({ say: "api_req_started", text: JSON.stringify({ tokensIn: 100 }) }), // No cost = streaming
createMessage({ say: "text", text: "Some text" }),
]
const state = detectAgentState(messages)
// Last message is say:text, but api_req_started has no cost
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
})
describe("Rapid state transitions", () => {
it("should handle multiple rapid state changes", () => {
const { client } = createMockClient()
const states: AgentLoopState[] = []
client.onStateChange((event) => {
states.push(event.currentState.state)
})
// Rapid updates
client.handleMessage(createStateMessage([createMessage({ say: "text" })]))
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: true })]))
client.handleMessage(createStateMessage([createMessage({ type: "ask", ask: "tool", partial: false })]))
client.handleMessage(
createStateMessage([createMessage({ type: "ask", ask: "completion_result", partial: false })]),
)
// Should have tracked all transitions
expect(states.length).toBeGreaterThanOrEqual(3)
expect(states).toContain(AgentLoopState.STREAMING)
expect(states).toContain(AgentLoopState.WAITING_FOR_INPUT)
expect(states).toContain(AgentLoopState.IDLE)
})
})
describe("Message array edge cases", () => {
it("should handle single message array", () => {
const messages = [createMessage({ say: "text", text: "Hello" })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
expect(state.lastMessage).toBeDefined()
expect(state.lastMessageTs).toBe(messages[0]!.ts)
})
it("should use last message for state detection", () => {
// Multiple messages, last one determines state
const messages = [
createMessage({ type: "ask", ask: "tool", partial: false }),
createMessage({ say: "text", text: "Tool executed" }),
createMessage({ type: "ask", ask: "completion_result", partial: false }),
]
const state = detectAgentState(messages)
// Last message is completion_result, so IDLE
expect(state.state).toBe(AgentLoopState.IDLE)
expect(state.currentAsk).toBe("completion_result")
})
it("should handle very long message arrays", () => {
// Create many messages
const messages: ClineMessage[] = []
for (let i = 0; i < 100; i++) {
messages.push(createMessage({ say: "text", text: `Message ${i}` }))
}
messages.push(createMessage({ type: "ask", ask: "followup", partial: false }))
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state.currentAsk).toBe("followup")
})
})
describe("State message edge cases", () => {
it("should handle state message with empty clineMessages", () => {
const { client } = createMockClient()
client.handleMessage({
type: "state",
state: {
clineMessages: [],
},
})
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
expect(client.isInitialized()).toBe(true)
})
it("should handle state message with missing clineMessages", () => {
const { client } = createMockClient()
client.handleMessage({
type: "state",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
state: {} as any,
})
// Should not crash, state should remain unchanged
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
})
it("should handle state message with missing state field", () => {
const { client } = createMockClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client.handleMessage({ type: "state" } as any)
// Should not crash
expect(client.getCurrentState()).toBe(AgentLoopState.NO_TASK)
})
})
describe("Partial to complete transitions", () => {
it("should transition from streaming to waiting when partial becomes false", () => {
const ts = Date.now()
const messages1 = [createMessage({ ts, type: "ask", ask: "tool", partial: true })]
const messages2 = [createMessage({ ts, type: "ask", ask: "tool", partial: false })]
const state1 = detectAgentState(messages1)
const state2 = detectAgentState(messages2)
expect(state1.state).toBe(AgentLoopState.STREAMING)
expect(state1.isWaitingForInput).toBe(false)
expect(state2.state).toBe(AgentLoopState.WAITING_FOR_INPUT)
expect(state2.isWaitingForInput).toBe(true)
})
it("should handle partial say messages", () => {
const messages = [createMessage({ say: "text", text: "Typing...", partial: true })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.STREAMING)
expect(state.isStreaming).toBe(true)
})
})
describe("Unknown message types", () => {
it("should handle unknown ask types gracefully", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = [createMessage({ type: "ask", ask: "unknown_type" as any, partial: false })]
const state = detectAgentState(messages)
// Unknown ask type should default to RUNNING
expect(state.state).toBe(AgentLoopState.RUNNING)
})
it("should handle unknown say types gracefully", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages = [createMessage({ say: "unknown_say_type" as any })]
const state = detectAgentState(messages)
expect(state.state).toBe(AgentLoopState.RUNNING)
})
})
})

View file

@ -0,0 +1,567 @@
/**
* Roo Code Client
*
* This is the main entry point for the client library. It provides a high-level
* API for:
* - Processing messages from the extension host
* - Querying the current agent state
* - Subscribing to state change events
* - Sending responses back to the extension
*
* The client is designed to be transport-agnostic. You provide a way to send
* messages to the extension, and you feed incoming messages to the client.
*
* Architecture:
* ```
*
* ExtensionClient
*
* Extension MessageProcessor StateStore
* Messages
*
* TypedEventEmitter State/Events
*
*
* Your Event Handlers
*
* ```
*/
import { StateStore } from "./state-store.js"
import { MessageProcessor, MessageProcessorOptions, parseExtensionMessage } from "./message-processor.js"
import {
TypedEventEmitter,
type ClientEventMap,
type AgentStateChangeEvent,
type WaitingForInputEvent,
} from "./events.js"
import { AgentLoopState, type AgentStateInfo } from "./agent-state.js"
import type { ExtensionMessage, WebviewMessage, ClineAskResponse, ClineMessage, ClineAsk } from "./types.js"
// =============================================================================
// Client Configuration
// =============================================================================
/**
* Configuration options for the ExtensionClient.
*/
export interface ExtensionClientConfig {
/**
* Function to send messages to the extension host.
* This is how the client communicates back to the extension.
*
* Example implementations:
* - VSCode webview: (msg) => vscode.postMessage(msg)
* - WebSocket: (msg) => socket.send(JSON.stringify(msg))
* - IPC: (msg) => process.send(msg)
*/
sendMessage: (message: WebviewMessage) => void
/**
* Whether to emit events for all state changes or only significant ones.
* Default: true
*/
emitAllStateChanges?: boolean
/**
* Enable debug logging.
* Default: false
*/
debug?: boolean
/**
* Maximum state history size (for debugging).
* Set to 0 to disable history tracking.
* Default: 0
*/
maxHistorySize?: number
}
// =============================================================================
// Main Client Class
// =============================================================================
/**
* ExtensionClient is the main interface for interacting with the Roo Code extension.
*
* Basic usage:
* ```typescript
* // Create client with message sender
* const client = new ExtensionClient({
* sendMessage: (msg) => vscode.postMessage(msg)
* })
*
* // Subscribe to state changes
* client.on('stateChange', (event) => {
* console.log('State:', event.currentState.state)
* })
*
* // Subscribe to specific events
* client.on('waitingForInput', (event) => {
* console.log('Waiting for:', event.ask)
* })
*
* // Feed messages from extension
* window.addEventListener('message', (e) => {
* client.handleMessage(e.data)
* })
*
* // Query state at any time
* const state = client.getAgentState()
* if (state.isWaitingForInput) {
* // Show approval UI
* }
*
* // Send responses
* client.approve() // or client.reject() or client.respond('answer')
* ```
*/
export class ExtensionClient {
private store: StateStore
private processor: MessageProcessor
private emitter: TypedEventEmitter
private sendMessage: (message: WebviewMessage) => void
private debug: boolean
constructor(config: ExtensionClientConfig) {
this.sendMessage = config.sendMessage
this.debug = config.debug ?? false
// Initialize components
this.store = new StateStore({
maxHistorySize: config.maxHistorySize ?? 0,
})
this.emitter = new TypedEventEmitter()
const processorOptions: MessageProcessorOptions = {
emitAllStateChanges: config.emitAllStateChanges ?? true,
debug: config.debug ?? false,
}
this.processor = new MessageProcessor(this.store, this.emitter, processorOptions)
}
// ===========================================================================
// Message Handling
// ===========================================================================
/**
* Handle an incoming message from the extension host.
*
* Call this method whenever you receive a message from the extension.
* The client will parse, validate, and process the message, updating
* internal state and emitting appropriate events.
*
* @param message - The raw message (can be ExtensionMessage or JSON string)
*/
handleMessage(message: ExtensionMessage | string): void {
let parsed: ExtensionMessage | undefined
if (typeof message === "string") {
parsed = parseExtensionMessage(message)
if (!parsed) {
if (this.debug) {
console.log("[ExtensionClient] Failed to parse message:", message)
}
return
}
} else {
parsed = message
}
this.processor.processMessage(parsed)
}
/**
* Handle multiple messages at once.
*/
handleMessages(messages: (ExtensionMessage | string)[]): void {
for (const message of messages) {
this.handleMessage(message)
}
}
// ===========================================================================
// State Queries - Always know the current state
// ===========================================================================
/**
* Get the complete agent state information.
*
* This returns everything you need to know about the current state:
* - The high-level state (running, streaming, waiting, idle, etc.)
* - Whether input is needed
* - The specific ask type if waiting
* - What action is required
* - Human-readable description
*/
getAgentState(): AgentStateInfo {
return this.store.getAgentState()
}
/**
* Get just the current state enum value.
*/
getCurrentState(): AgentLoopState {
return this.store.getCurrentState()
}
/**
* Check if the agent is waiting for user input.
*/
isWaitingForInput(): boolean {
return this.store.isWaitingForInput()
}
/**
* Check if the agent is actively running.
*/
isRunning(): boolean {
return this.store.isRunning()
}
/**
* Check if content is currently streaming.
*/
isStreaming(): boolean {
return this.store.isStreaming()
}
/**
* Check if there is an active task.
*/
hasActiveTask(): boolean {
return this.store.getCurrentState() !== AgentLoopState.NO_TASK
}
/**
* Get all messages in the current task.
*/
getMessages(): ClineMessage[] {
return this.store.getMessages()
}
/**
* Get the last message.
*/
getLastMessage(): ClineMessage | undefined {
return this.store.getLastMessage()
}
/**
* Get the current ask type if the agent is waiting for input.
*/
getCurrentAsk(): ClineAsk | undefined {
return this.store.getAgentState().currentAsk
}
/**
* Check if the client has received any state from the extension.
*/
isInitialized(): boolean {
return this.store.isInitialized()
}
// ===========================================================================
// Event Subscriptions - Realtime notifications
// ===========================================================================
/**
* Subscribe to an event.
*
* Returns an unsubscribe function for easy cleanup.
*
* @param event - The event to subscribe to
* @param listener - The callback function
* @returns Unsubscribe function
*
* @example
* ```typescript
* const unsubscribe = client.on('stateChange', (event) => {
* console.log(event.currentState)
* })
*
* // Later, to unsubscribe:
* unsubscribe()
* ```
*/
on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
return this.emitter.on(event, listener)
}
/**
* Subscribe to an event, triggered only once.
*/
once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.once(event, listener)
}
/**
* Unsubscribe from an event.
*/
off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.off(event, listener)
}
/**
* Remove all listeners for an event, or all events.
*/
removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
this.emitter.removeAllListeners(event)
}
/**
* Convenience method: Subscribe only to state changes.
*/
onStateChange(listener: (event: AgentStateChangeEvent) => void): () => void {
return this.on("stateChange", listener)
}
/**
* Convenience method: Subscribe only to waiting events.
*/
onWaitingForInput(listener: (event: WaitingForInputEvent) => void): () => void {
return this.on("waitingForInput", listener)
}
// ===========================================================================
// Response Methods - Send actions to the extension
// ===========================================================================
/**
* Approve the current action (tool, command, browser, MCP).
*
* Use when the agent is waiting for approval (interactive asks).
*/
approve(): void {
this.sendResponse("yesButtonClicked")
}
/**
* Reject the current action.
*
* Use when you want to deny a tool, command, or other action.
*/
reject(): void {
this.sendResponse("noButtonClicked")
}
/**
* Send a text response.
*
* Use for:
* - Answering follow-up questions
* - Providing additional context
* - Giving feedback on completion
*
* @param text - The response text
* @param images - Optional base64-encoded images
*/
respond(text: string, images?: string[]): void {
this.sendResponse("messageResponse", text, images)
}
/**
* Generic method to send any ask response.
*
* @param response - The response type
* @param text - Optional text content
* @param images - Optional images
*/
sendResponse(response: ClineAskResponse, text?: string, images?: string[]): void {
const message: WebviewMessage = {
type: "askResponse",
askResponse: response,
text,
images,
}
this.sendMessage(message)
}
// ===========================================================================
// Task Control Methods
// ===========================================================================
/**
* Start a new task with the given prompt.
*
* @param text - The task description/prompt
* @param images - Optional base64-encoded images
*/
newTask(text: string, images?: string[]): void {
const message: WebviewMessage = {
type: "newTask",
text,
images,
}
this.sendMessage(message)
}
/**
* Clear the current task.
*
* This ends the current task and resets to a fresh state.
*/
clearTask(): void {
const message: WebviewMessage = {
type: "clearTask",
}
this.sendMessage(message)
this.processor.notifyTaskCleared()
}
/**
* Cancel a running task.
*
* Use this to interrupt a task that is currently processing.
*/
cancelTask(): void {
const message: WebviewMessage = {
type: "cancelTask",
}
this.sendMessage(message)
}
/**
* Resume a paused task.
*
* Use when the agent state is RESUMABLE (resume_task ask).
*/
resumeTask(): void {
this.approve() // Resume uses the same response as approve
}
/**
* Retry a failed API request.
*
* Use when the agent state shows api_req_failed.
*/
retryApiRequest(): void {
this.approve() // Retry uses the same response as approve
}
// ===========================================================================
// Terminal Operation Methods
// ===========================================================================
/**
* Continue terminal output (don't wait for more output).
*
* Use when the agent is showing command_output and you want to proceed.
*/
continueTerminal(): void {
const message: WebviewMessage = {
type: "terminalOperation",
terminalOperation: "continue",
}
this.sendMessage(message)
}
/**
* Abort terminal command.
*
* Use when you want to kill a running terminal command.
*/
abortTerminal(): void {
const message: WebviewMessage = {
type: "terminalOperation",
terminalOperation: "abort",
}
this.sendMessage(message)
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Reset the client state.
*
* This clears all internal state and history.
* Useful when disconnecting or starting fresh.
*/
reset(): void {
this.store.reset()
this.emitter.removeAllListeners()
}
/**
* Get the state history (if history tracking is enabled).
*/
getStateHistory() {
return this.store.getHistory()
}
/**
* Enable or disable debug mode.
*/
setDebug(enabled: boolean): void {
this.debug = enabled
this.processor.setDebug(enabled)
}
// ===========================================================================
// Advanced: Direct Store Access
// ===========================================================================
/**
* Get direct access to the state store.
*
* This is for advanced use cases where you need more control.
* Most users should use the methods above instead.
*/
getStore(): StateStore {
return this.store
}
/**
* Get direct access to the event emitter.
*/
getEmitter(): TypedEventEmitter {
return this.emitter
}
}
// =============================================================================
// Factory Functions
// =============================================================================
/**
* Create a new ExtensionClient instance.
*
* This is a convenience function that creates a client with default settings.
*
* @param sendMessage - Function to send messages to the extension
* @returns A new ExtensionClient instance
*/
export function createClient(sendMessage: (message: WebviewMessage) => void): ExtensionClient {
return new ExtensionClient({ sendMessage })
}
/**
* Create a mock client for testing.
*
* The mock client captures all sent messages for verification.
*
* @returns An object with the client and captured messages
*/
export function createMockClient(): {
client: ExtensionClient
sentMessages: WebviewMessage[]
clearMessages: () => void
} {
const sentMessages: WebviewMessage[] = []
const client = new ExtensionClient({
sendMessage: (message) => sentMessages.push(message),
debug: false,
})
return {
client,
sentMessages,
clearMessages: () => {
sentMessages.length = 0
},
}
}

View file

@ -0,0 +1,355 @@
/**
* Event System for Agent State Changes
*
* This module provides a strongly-typed event emitter specifically designed
* for tracking agent state changes. It uses Node.js EventEmitter under the hood
* but provides type safety for all events.
*/
import { EventEmitter } from "events"
import type { AgentStateInfo } from "./agent-state.js"
import type { ClineMessage, ClineAsk } from "./types.js"
// =============================================================================
// Event Types
// =============================================================================
/**
* All events that can be emitted by the client.
*
* Design note: We use a string literal union type for event names to ensure
* type safety when subscribing to events. The payload type is determined by
* the event name.
*/
export interface ClientEventMap {
/**
* Emitted whenever the agent state changes.
* This is the primary event for tracking state.
*/
stateChange: AgentStateChangeEvent
/**
* Emitted when a new message is added to the message list.
*/
message: ClineMessage
/**
* Emitted when an existing message is updated (e.g., partial -> complete).
*/
messageUpdated: ClineMessage
/**
* Emitted when the agent starts waiting for user input.
* Convenience event - you can also use stateChange.
*/
waitingForInput: WaitingForInputEvent
/**
* Emitted when the agent stops waiting and resumes running.
*/
resumedRunning: void
/**
* Emitted when the agent starts streaming content.
*/
streamingStarted: void
/**
* Emitted when streaming ends.
*/
streamingEnded: void
/**
* Emitted when a task completes (either successfully or with error).
*/
taskCompleted: TaskCompletedEvent
/**
* Emitted when a task is cleared/cancelled.
*/
taskCleared: void
/**
* Emitted on any error during message processing.
*/
error: Error
}
/**
* Event payload for state changes.
*/
export interface AgentStateChangeEvent {
/** The previous state info */
previousState: AgentStateInfo
/** The new/current state info */
currentState: AgentStateInfo
/** Whether this is a significant state transition (state enum changed) */
isSignificantChange: boolean
}
/**
* Event payload when agent starts waiting for input.
*/
export interface WaitingForInputEvent {
/** The specific ask type */
ask: ClineAsk
/** Full state info for context */
stateInfo: AgentStateInfo
/** The message that triggered this wait */
message: ClineMessage
}
/**
* Event payload when a task completes.
*/
export interface TaskCompletedEvent {
/** Whether the task completed successfully */
success: boolean
/** The final state info */
stateInfo: AgentStateInfo
/** The completion message if available */
message?: ClineMessage
}
// =============================================================================
// Typed Event Emitter
// =============================================================================
/**
* Type-safe event emitter for client events.
*
* Usage:
* ```typescript
* const emitter = new TypedEventEmitter()
*
* // Type-safe subscription
* emitter.on('stateChange', (event) => {
* console.log(event.currentState) // TypeScript knows this is AgentStateChangeEvent
* })
*
* // Type-safe emission
* emitter.emit('stateChange', { previousState, currentState, isSignificantChange })
* ```
*/
export class TypedEventEmitter {
private emitter = new EventEmitter()
/**
* Subscribe to an event.
*
* @param event - The event name
* @param listener - The callback function
* @returns Function to unsubscribe
*/
on<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): () => void {
this.emitter.on(event, listener)
return () => this.emitter.off(event, listener)
}
/**
* Subscribe to an event, but only once.
*
* @param event - The event name
* @param listener - The callback function
*/
once<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.once(event, listener)
}
/**
* Unsubscribe from an event.
*
* @param event - The event name
* @param listener - The callback function to remove
*/
off<K extends keyof ClientEventMap>(event: K, listener: (payload: ClientEventMap[K]) => void): void {
this.emitter.off(event, listener)
}
/**
* Emit an event.
*
* @param event - The event name
* @param payload - The event payload
*/
emit<K extends keyof ClientEventMap>(event: K, payload: ClientEventMap[K]): void {
this.emitter.emit(event, payload)
}
/**
* Remove all listeners for an event, or all events.
*
* @param event - Optional event name. If not provided, removes all listeners.
*/
removeAllListeners<K extends keyof ClientEventMap>(event?: K): void {
if (event) {
this.emitter.removeAllListeners(event)
} else {
this.emitter.removeAllListeners()
}
}
/**
* Get the number of listeners for an event.
*/
listenerCount<K extends keyof ClientEventMap>(event: K): number {
return this.emitter.listenerCount(event)
}
}
// =============================================================================
// State Change Detector
// =============================================================================
/**
* Helper to determine if a state change is "significant".
*
* A significant change is when the AgentLoopState enum value changes,
* as opposed to just internal state updates within the same state.
*/
export function isSignificantStateChange(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.state !== current.state
}
/**
* Helper to determine if we transitioned to waiting for input.
*/
export function transitionedToWaiting(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return !previous.isWaitingForInput && current.isWaitingForInput
}
/**
* Helper to determine if we transitioned from waiting to running.
*/
export function transitionedToRunning(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.isWaitingForInput && !current.isWaitingForInput && current.isRunning
}
/**
* Helper to determine if streaming started.
*/
export function streamingStarted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return !previous.isStreaming && current.isStreaming
}
/**
* Helper to determine if streaming ended.
*/
export function streamingEnded(previous: AgentStateInfo, current: AgentStateInfo): boolean {
return previous.isStreaming && !current.isStreaming
}
/**
* Helper to determine if task completed.
*/
export function taskCompleted(previous: AgentStateInfo, current: AgentStateInfo): boolean {
const completionAsks = ["completion_result", "api_req_failed", "mistake_limit_reached"]
const wasNotComplete = !previous.currentAsk || !completionAsks.includes(previous.currentAsk)
const isNowComplete = current.currentAsk !== undefined && completionAsks.includes(current.currentAsk)
return wasNotComplete && isNowComplete
}
// =============================================================================
// Observable Pattern (Alternative API)
// =============================================================================
/**
* Subscription function type for observable pattern.
*/
export type Observer<T> = (value: T) => void
/**
* Unsubscribe function type.
*/
export type Unsubscribe = () => void
/**
* Simple observable for state.
*
* This provides an alternative to the event emitter pattern
* for those who prefer a more functional approach.
*
* Usage:
* ```typescript
* const stateObservable = new Observable<AgentStateInfo>()
*
* const unsubscribe = stateObservable.subscribe((state) => {
* console.log('New state:', state)
* })
*
* // Later...
* unsubscribe()
* ```
*/
export class Observable<T> {
private observers: Set<Observer<T>> = new Set()
private currentValue: T | undefined
/**
* Create an observable with an optional initial value.
*/
constructor(initialValue?: T) {
this.currentValue = initialValue
}
/**
* Subscribe to value changes.
*
* @param observer - Function called when value changes
* @returns Unsubscribe function
*/
subscribe(observer: Observer<T>): Unsubscribe {
this.observers.add(observer)
// Immediately emit current value if we have one
if (this.currentValue !== undefined) {
observer(this.currentValue)
}
return () => {
this.observers.delete(observer)
}
}
/**
* Update the value and notify all subscribers.
*/
next(value: T): void {
this.currentValue = value
for (const observer of this.observers) {
try {
observer(value)
} catch (error) {
console.error("Error in observer:", error)
}
}
}
/**
* Get the current value without subscribing.
*/
getValue(): T | undefined {
return this.currentValue
}
/**
* Check if there are any subscribers.
*/
hasSubscribers(): boolean {
return this.observers.size > 0
}
/**
* Get the number of subscribers.
*/
getSubscriberCount(): number {
return this.observers.size
}
/**
* Remove all subscribers.
*/
clear(): void {
this.observers.clear()
}
}

View file

@ -0,0 +1,79 @@
/**
* Roo Code Client Library
*
* Provides state detection and event-based tracking for the Roo Code agent loop.
*/
// Main Client
export { ExtensionClient, createClient, createMockClient } from "./client.js"
// State Detection
export {
AgentLoopState,
type AgentStateInfo,
type RequiredAction,
detectAgentState,
isAgentWaitingForInput,
isAgentRunning,
isContentStreaming,
} from "./agent-state.js"
// Events
export {
TypedEventEmitter,
Observable,
type Observer,
type Unsubscribe,
type ClientEventMap,
type AgentStateChangeEvent,
type WaitingForInputEvent,
type TaskCompletedEvent,
isSignificantStateChange,
transitionedToWaiting,
transitionedToRunning,
streamingStarted,
streamingEnded,
taskCompleted,
} from "./events.js"
// State Store
export { StateStore, type StoreState, getDefaultStore, resetDefaultStore } from "./state-store.js"
// Message Processing
export {
MessageProcessor,
type MessageProcessorOptions,
isValidClineMessage,
isValidExtensionMessage,
parseExtensionMessage,
parseApiReqStartedText,
} from "./message-processor.js"
// Types - Re-exported from @roo-code/types
export {
type ClineAsk,
type IdleAsk,
type ResumableAsk,
type InteractiveAsk,
type NonBlockingAsk,
clineAsks,
idleAsks,
resumableAsks,
interactiveAsks,
nonBlockingAsks,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
type ClineSay,
clineSays,
type ClineMessage,
type ToolProgressStatus,
type ContextCondense,
type ContextTruncation,
type ClineAskResponse,
type WebviewMessage,
type ExtensionMessage,
type ExtensionState,
type ApiReqStartedText,
} from "./types.js"

View file

@ -0,0 +1,465 @@
/**
* Message Processor
*
* This module handles incoming messages from the extension host and dispatches
* appropriate state updates and events. It acts as the bridge between raw
* extension messages and the client's internal state management.
*
* Message Flow:
* ```
* Extension Host MessageProcessor StateStore Events
* ```
*
* The processor handles different message types:
* - "state": Full state update from extension
* - "messageUpdated": Single message update
* - "action": UI action triggers
* - "invoke": Command invocations
*/
import { debugLog } from "@roo-code/core/cli"
import type { ExtensionMessage, ClineMessage } from "./types.js"
import type { StateStore } from "./state-store.js"
import type { TypedEventEmitter, AgentStateChangeEvent, WaitingForInputEvent, TaskCompletedEvent } from "./events.js"
import {
isSignificantStateChange,
transitionedToWaiting,
transitionedToRunning,
streamingStarted,
streamingEnded,
taskCompleted,
} from "./events.js"
import type { AgentStateInfo } from "./agent-state.js"
// =============================================================================
// Message Processor Options
// =============================================================================
export interface MessageProcessorOptions {
/**
* Whether to emit events for every state change, or only significant ones.
* Default: true (emit all changes)
*/
emitAllStateChanges?: boolean
/**
* Whether to log debug information.
* Default: false
*/
debug?: boolean
}
// =============================================================================
// Message Processor Class
// =============================================================================
/**
* MessageProcessor handles incoming extension messages and updates state accordingly.
*
* It is responsible for:
* 1. Parsing and validating incoming messages
* 2. Updating the state store
* 3. Emitting appropriate events
*
* Usage:
* ```typescript
* const store = new StateStore()
* const emitter = new TypedEventEmitter()
* const processor = new MessageProcessor(store, emitter)
*
* // Process a message from the extension
* processor.processMessage(extensionMessage)
* ```
*/
export class MessageProcessor {
private store: StateStore
private emitter: TypedEventEmitter
private options: Required<MessageProcessorOptions>
constructor(store: StateStore, emitter: TypedEventEmitter, options: MessageProcessorOptions = {}) {
this.store = store
this.emitter = emitter
this.options = {
emitAllStateChanges: options.emitAllStateChanges ?? true,
debug: options.debug ?? false,
}
}
// ===========================================================================
// Main Processing Methods
// ===========================================================================
/**
* Process an incoming message from the extension host.
*
* This is the main entry point for all extension messages.
* It routes messages to the appropriate handler based on type.
*
* @param message - The raw message from the extension
*/
processMessage(message: ExtensionMessage): void {
if (this.options.debug) {
debugLog("[MessageProcessor] Received message", { type: message.type })
}
try {
switch (message.type) {
case "state":
this.handleStateMessage(message)
break
case "messageUpdated":
this.handleMessageUpdated(message)
break
case "action":
this.handleAction(message)
break
case "invoke":
this.handleInvoke(message)
break
default:
// Other message types are not relevant to state detection
if (this.options.debug) {
debugLog("[MessageProcessor] Ignoring message", { type: message.type })
}
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
debugLog("[MessageProcessor] Error processing message", { error: err.message })
this.emitter.emit("error", err)
}
}
/**
* Process an array of messages (for batch updates).
*/
processMessages(messages: ExtensionMessage[]): void {
for (const message of messages) {
this.processMessage(message)
}
}
// ===========================================================================
// Message Type Handlers
// ===========================================================================
/**
* Handle a "state" message - full state update from extension.
*
* This is the most important message type for state detection.
* It contains the complete clineMessages array which is the source of truth.
*/
private handleStateMessage(message: ExtensionMessage): void {
if (!message.state) {
if (this.options.debug) {
debugLog("[MessageProcessor] State message missing state payload")
}
return
}
const { clineMessages } = message.state
if (!clineMessages) {
if (this.options.debug) {
debugLog("[MessageProcessor] State message missing clineMessages")
}
return
}
// Get previous state for comparison
const previousState = this.store.getAgentState()
// Update the store with new messages
// Note: We only call setMessages, NOT setExtensionState, to avoid
// double processing (setExtensionState would call setMessages again)
this.store.setMessages(clineMessages)
// Get new state after update
const currentState = this.store.getAgentState()
// Debug logging for state message
if (this.options.debug) {
const lastMsg = clineMessages[clineMessages.length - 1]
const lastMsgInfo = lastMsg
? {
msgType: lastMsg.type === "ask" ? `ask:${lastMsg.ask}` : `say:${lastMsg.say}`,
partial: lastMsg.partial,
textPreview: lastMsg.text?.substring(0, 50),
}
: null
debugLog("[MessageProcessor] State update", {
messageCount: clineMessages.length,
lastMessage: lastMsgInfo,
stateTransition: `${previousState.state}${currentState.state}`,
currentAsk: currentState.currentAsk,
isWaitingForInput: currentState.isWaitingForInput,
isStreaming: currentState.isStreaming,
isRunning: currentState.isRunning,
})
}
// Emit events based on state changes
this.emitStateChangeEvents(previousState, currentState)
// Emit new message events for any messages we haven't seen
this.emitNewMessageEvents(previousState, currentState, clineMessages)
}
/**
* Handle a "messageUpdated" message - single message update.
*
* This is sent when a message is modified (e.g., partial -> complete).
*/
private handleMessageUpdated(message: ExtensionMessage): void {
if (!message.clineMessage) {
if (this.options.debug) {
debugLog("[MessageProcessor] messageUpdated missing clineMessage")
}
return
}
const clineMessage = message.clineMessage
const previousState = this.store.getAgentState()
// Update the message in the store
this.store.updateMessage(clineMessage)
const currentState = this.store.getAgentState()
// Emit message updated event
this.emitter.emit("messageUpdated", clineMessage)
// Emit state change events
this.emitStateChangeEvents(previousState, currentState)
}
/**
* Handle an "action" message - UI action trigger.
*
* These are typically used to trigger UI behaviors and don't
* directly affect agent state, but we can track them if needed.
*/
private handleAction(message: ExtensionMessage): void {
if (this.options.debug) {
debugLog("[MessageProcessor] Action", { action: message.action })
}
// Actions don't affect agent state, but subclasses could override this
}
/**
* Handle an "invoke" message - command invocation.
*
* These are commands that should trigger specific behaviors.
*/
private handleInvoke(message: ExtensionMessage): void {
if (this.options.debug) {
debugLog("[MessageProcessor] Invoke", { invoke: message.invoke })
}
// Invokes don't directly affect state detection
// But they might trigger state changes through subsequent messages
}
// ===========================================================================
// Event Emission Helpers
// ===========================================================================
/**
* Emit events based on state changes.
*/
private emitStateChangeEvents(previousState: AgentStateInfo, currentState: AgentStateInfo): void {
const isSignificant = isSignificantStateChange(previousState, currentState)
// Emit stateChange event
if (this.options.emitAllStateChanges || isSignificant) {
const changeEvent: AgentStateChangeEvent = {
previousState,
currentState,
isSignificantChange: isSignificant,
}
this.emitter.emit("stateChange", changeEvent)
}
// Emit specific transition events
// Waiting for input
if (transitionedToWaiting(previousState, currentState)) {
if (currentState.currentAsk && currentState.lastMessage) {
if (this.options.debug) {
debugLog("[MessageProcessor] EMIT waitingForInput", {
ask: currentState.currentAsk,
action: currentState.requiredAction,
})
}
const waitingEvent: WaitingForInputEvent = {
ask: currentState.currentAsk,
stateInfo: currentState,
message: currentState.lastMessage,
}
this.emitter.emit("waitingForInput", waitingEvent)
}
}
// Resumed running
if (transitionedToRunning(previousState, currentState)) {
if (this.options.debug) {
debugLog("[MessageProcessor] EMIT resumedRunning")
}
this.emitter.emit("resumedRunning", undefined as void)
}
// Streaming started
if (streamingStarted(previousState, currentState)) {
if (this.options.debug) {
debugLog("[MessageProcessor] EMIT streamingStarted")
}
this.emitter.emit("streamingStarted", undefined as void)
}
// Streaming ended
if (streamingEnded(previousState, currentState)) {
if (this.options.debug) {
debugLog("[MessageProcessor] EMIT streamingEnded")
}
this.emitter.emit("streamingEnded", undefined as void)
}
// Task completed
if (taskCompleted(previousState, currentState)) {
if (this.options.debug) {
debugLog("[MessageProcessor] EMIT taskCompleted", {
success: currentState.currentAsk === "completion_result",
})
}
const completedEvent: TaskCompletedEvent = {
success: currentState.currentAsk === "completion_result",
stateInfo: currentState,
message: currentState.lastMessage,
}
this.emitter.emit("taskCompleted", completedEvent)
}
}
/**
* Emit events for new messages.
*
* We compare the previous and current message counts to find new messages.
* This is a simple heuristic - for more accuracy, we'd track by timestamp.
*/
private emitNewMessageEvents(
_previousState: AgentStateInfo,
_currentState: AgentStateInfo,
messages: ClineMessage[],
): void {
// For now, just emit the last message as new
// A more sophisticated implementation would track seen message timestamps
const lastMessage = messages[messages.length - 1]
if (lastMessage) {
this.emitter.emit("message", lastMessage)
}
}
// ===========================================================================
// Utility Methods
// ===========================================================================
/**
* Manually trigger a task cleared event.
* Call this when you send a clearTask message to the extension.
*/
notifyTaskCleared(): void {
this.store.clear()
this.emitter.emit("taskCleared", undefined as void)
}
/**
* Enable or disable debug logging.
*/
setDebug(enabled: boolean): void {
this.options.debug = enabled
}
}
// =============================================================================
// Message Validation Helpers
// =============================================================================
/**
* Check if a message is a valid ClineMessage.
* Useful for validating messages before processing.
*/
export function isValidClineMessage(message: unknown): message is ClineMessage {
if (!message || typeof message !== "object") {
return false
}
const msg = message as Record<string, unknown>
// Required fields
if (typeof msg.ts !== "number") {
return false
}
if (msg.type !== "ask" && msg.type !== "say") {
return false
}
return true
}
/**
* Check if a message is a valid ExtensionMessage.
*/
export function isValidExtensionMessage(message: unknown): message is ExtensionMessage {
if (!message || typeof message !== "object") {
return false
}
const msg = message as Record<string, unknown>
// Must have a type
if (typeof msg.type !== "string") {
return false
}
return true
}
// =============================================================================
// Message Parsing Utilities
// =============================================================================
/**
* Parse a JSON string into an ExtensionMessage.
* Returns undefined if parsing fails.
*/
export function parseExtensionMessage(json: string): ExtensionMessage | undefined {
try {
const parsed = JSON.parse(json)
if (isValidExtensionMessage(parsed)) {
return parsed
}
return undefined
} catch {
return undefined
}
}
/**
* Parse the text field of an api_req_started message.
* Returns undefined if parsing fails or text is not present.
*/
export function parseApiReqStartedText(message: ClineMessage): { cost?: number } | undefined {
if (message.say !== "api_req_started" || !message.text) {
return undefined
}
try {
return JSON.parse(message.text)
} catch {
return undefined
}
}

View file

@ -0,0 +1,380 @@
/**
* State Store
*
* This module manages the client's internal state, including:
* - The clineMessages array (source of truth for agent state)
* - The computed agent state info
* - Any extension state we want to cache
*
* The store is designed to be:
* - Immutable: State updates create new objects, not mutations
* - Observable: Changes trigger notifications
* - Queryable: Current state is always accessible
*/
import { detectAgentState, AgentStateInfo, AgentLoopState } from "./agent-state.js"
import type { ClineMessage, ExtensionState } from "./types.js"
import { Observable } from "./events.js"
// =============================================================================
// Store State Interface
// =============================================================================
/**
* The complete state managed by the store.
*/
export interface StoreState {
/**
* The array of messages from the extension.
* This is the primary data used to compute agent state.
*/
messages: ClineMessage[]
/**
* The computed agent state info.
* Updated automatically when messages change.
*/
agentState: AgentStateInfo
/**
* Whether we have received any state from the extension.
* Useful to distinguish "no task" from "not yet connected".
*/
isInitialized: boolean
/**
* The last time state was updated.
*/
lastUpdatedAt: number
/**
* Optional: Cache of extension state fields we might need.
* This is a subset of the full ExtensionState.
*/
extensionState?: Partial<ExtensionState>
}
/**
* Create the initial store state.
*/
function createInitialState(): StoreState {
return {
messages: [],
agentState: detectAgentState([]),
isInitialized: false,
lastUpdatedAt: Date.now(),
}
}
// =============================================================================
// State Store Class
// =============================================================================
/**
* StateStore manages all client state and provides reactive updates.
*
* Key features:
* - Stores the clineMessages array
* - Automatically computes agent state when messages change
* - Provides observable pattern for state changes
* - Tracks state history for debugging (optional)
*
* Usage:
* ```typescript
* const store = new StateStore()
*
* // Subscribe to state changes
* store.subscribe((state) => {
* console.log('New state:', state.agentState.state)
* })
*
* // Update messages
* store.setMessages(newMessages)
*
* // Query current state
* const currentState = store.getState()
* ```
*/
export class StateStore {
private state: StoreState
private stateObservable: Observable<StoreState>
private agentStateObservable: Observable<AgentStateInfo>
/**
* Optional: Track state history for debugging.
* Set maxHistorySize to enable.
*/
private stateHistory: StoreState[] = []
private maxHistorySize: number
constructor(options: { maxHistorySize?: number } = {}) {
this.state = createInitialState()
this.stateObservable = new Observable<StoreState>(this.state)
this.agentStateObservable = new Observable<AgentStateInfo>(this.state.agentState)
this.maxHistorySize = options.maxHistorySize ?? 0
}
// ===========================================================================
// State Queries
// ===========================================================================
/**
* Get the current complete state.
*/
getState(): StoreState {
return this.state
}
/**
* Get just the agent state info.
* This is a convenience method for the most common query.
*/
getAgentState(): AgentStateInfo {
return this.state.agentState
}
/**
* Get the current messages array.
*/
getMessages(): ClineMessage[] {
return this.state.messages
}
/**
* Get the last message, if any.
*/
getLastMessage(): ClineMessage | undefined {
return this.state.messages[this.state.messages.length - 1]
}
/**
* Check if the store has been initialized with extension state.
*/
isInitialized(): boolean {
return this.state.isInitialized
}
/**
* Quick check: Is the agent currently waiting for input?
*/
isWaitingForInput(): boolean {
return this.state.agentState.isWaitingForInput
}
/**
* Quick check: Is the agent currently running?
*/
isRunning(): boolean {
return this.state.agentState.isRunning
}
/**
* Quick check: Is content currently streaming?
*/
isStreaming(): boolean {
return this.state.agentState.isStreaming
}
/**
* Get the current agent loop state enum value.
*/
getCurrentState(): AgentLoopState {
return this.state.agentState.state
}
// ===========================================================================
// State Updates
// ===========================================================================
/**
* Set the complete messages array.
* This is typically called when receiving a full state update from the extension.
*
* @param messages - The new messages array
* @returns The previous agent state (for comparison)
*/
setMessages(messages: ClineMessage[]): AgentStateInfo {
const previousAgentState = this.state.agentState
const newAgentState = detectAgentState(messages)
this.updateState({
messages,
agentState: newAgentState,
isInitialized: true,
lastUpdatedAt: Date.now(),
})
return previousAgentState
}
/**
* Add a single message to the end of the messages array.
* Useful when receiving incremental updates.
*
* @param message - The message to add
* @returns The previous agent state
*/
addMessage(message: ClineMessage): AgentStateInfo {
const newMessages = [...this.state.messages, message]
return this.setMessages(newMessages)
}
/**
* Update a message in place (e.g., when partial becomes complete).
* Finds the message by timestamp and replaces it.
*
* @param message - The updated message
* @returns The previous agent state, or undefined if message not found
*/
updateMessage(message: ClineMessage): AgentStateInfo | undefined {
const index = this.state.messages.findIndex((m) => m.ts === message.ts)
if (index === -1) {
// Message not found, add it instead
return this.addMessage(message)
}
const newMessages = [...this.state.messages]
newMessages[index] = message
return this.setMessages(newMessages)
}
/**
* Clear all messages and reset to initial state.
* Called when a task is cleared/cancelled.
*/
clear(): void {
this.updateState({
messages: [],
agentState: detectAgentState([]),
isInitialized: true, // Still initialized, just empty
lastUpdatedAt: Date.now(),
extensionState: undefined,
})
}
/**
* Reset to completely uninitialized state.
* Called on disconnect or reset.
*/
reset(): void {
this.state = createInitialState()
this.stateHistory = []
// Don't notify on reset - we're starting fresh
}
/**
* Update cached extension state.
* This stores any additional extension state fields we might need.
*
* @param extensionState - The extension state to cache
*/
setExtensionState(extensionState: Partial<ExtensionState>): void {
// Extract and store messages if present
if (extensionState.clineMessages) {
this.setMessages(extensionState.clineMessages)
}
// Store the rest of the extension state
this.updateState({
...this.state,
extensionState: {
...this.state.extensionState,
...extensionState,
},
})
}
// ===========================================================================
// Subscriptions
// ===========================================================================
/**
* Subscribe to all state changes.
*
* @param observer - Callback function receiving the new state
* @returns Unsubscribe function
*/
subscribe(observer: (state: StoreState) => void): () => void {
return this.stateObservable.subscribe(observer)
}
/**
* Subscribe to agent state changes only.
* This is more efficient if you only care about agent state.
*
* @param observer - Callback function receiving the new agent state
* @returns Unsubscribe function
*/
subscribeToAgentState(observer: (state: AgentStateInfo) => void): () => void {
return this.agentStateObservable.subscribe(observer)
}
// ===========================================================================
// History (for debugging)
// ===========================================================================
/**
* Get the state history (if enabled).
*/
getHistory(): StoreState[] {
return [...this.stateHistory]
}
/**
* Clear the state history.
*/
clearHistory(): void {
this.stateHistory = []
}
// ===========================================================================
// Private Methods
// ===========================================================================
/**
* Internal method to update state and notify observers.
*/
private updateState(newState: StoreState): void {
// Track history if enabled
if (this.maxHistorySize > 0) {
this.stateHistory.push(this.state)
if (this.stateHistory.length > this.maxHistorySize) {
this.stateHistory.shift()
}
}
this.state = newState
// Notify observers
this.stateObservable.next(this.state)
this.agentStateObservable.next(this.state.agentState)
}
}
// =============================================================================
// Singleton Store (optional convenience)
// =============================================================================
let defaultStore: StateStore | null = null
/**
* Get the default singleton store instance.
* Useful for simple applications that don't need multiple stores.
*/
export function getDefaultStore(): StateStore {
if (!defaultStore) {
defaultStore = new StateStore()
}
return defaultStore
}
/**
* Reset the default store instance.
* Useful for testing or when you need a fresh start.
*/
export function resetDefaultStore(): void {
if (defaultStore) {
defaultStore.reset()
}
defaultStore = null
}

View file

@ -0,0 +1,88 @@
/**
* Type definitions for Roo Code client
*
* Re-exports types from @roo-code/types and adds client-specific types.
*/
import type { ClineMessage as RooCodeClineMessage, ExtensionMessage as RooCodeExtensionMessage } from "@roo-code/types"
// =============================================================================
// Re-export all types from @roo-code/types
// =============================================================================
// Message types
export type {
ClineAsk,
IdleAsk,
ResumableAsk,
InteractiveAsk,
NonBlockingAsk,
ClineSay,
ClineMessage,
ToolProgressStatus,
ContextCondense,
ContextTruncation,
} from "@roo-code/types"
// Ask arrays and type guards
export {
clineAsks,
idleAsks,
resumableAsks,
interactiveAsks,
nonBlockingAsks,
clineSays,
isIdleAsk,
isResumableAsk,
isInteractiveAsk,
isNonBlockingAsk,
} from "@roo-code/types"
// Webview message types
export type { WebviewMessage, ClineAskResponse } from "@roo-code/types"
// =============================================================================
// Client-specific types
// =============================================================================
/**
* Simplified ExtensionState for client purposes.
*
* The full ExtensionState from @roo-code/types has many required fields,
* but for agent loop state detection, we only need clineMessages.
* This type allows partial state updates while still being compatible
* with the full type.
*/
export interface ExtensionState {
clineMessages: RooCodeClineMessage[]
/** Allow other fields from the full ExtensionState to pass through */
[key: string]: unknown
}
/**
* Simplified ExtensionMessage for client purposes.
*
* We only care about certain message types for state detection.
* Other fields pass through unchanged.
*/
export interface ExtensionMessage {
type: RooCodeExtensionMessage["type"]
state?: ExtensionState
clineMessage?: RooCodeClineMessage
action?: string
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
/** Allow other fields to pass through */
[key: string]: unknown
}
/**
* Structure of the text field in api_req_started messages.
* Used to determine if the API request has completed (cost is defined).
*/
export interface ApiReqStartedText {
cost?: number // Undefined while streaming, defined when complete
tokensIn?: number
tokensOut?: number
cacheWrites?: number
cacheReads?: number
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,961 @@
// pnpm --filter @roo-code/cli test src/extension-host/__tests__/extension-host.test.ts
import { EventEmitter } from "events"
import fs from "fs"
import os from "os"
import path from "path"
import type { WebviewMessage } from "@roo-code/types"
import type { SupportedProvider } from "../../types/index.js"
import { type ExtensionHostOptions, ExtensionHost } from "../extension-host.js"
vi.mock("@roo-code/vscode-shim", () => ({
createVSCodeAPI: vi.fn(() => ({
context: { extensionPath: "/test/extension" },
})),
setRuntimeConfigValues: vi.fn(),
}))
/**
* Create a test ExtensionHost with default options
*/
function createTestHost({
mode = "code",
provider = "openrouter",
model = "test-model",
...options
}: Partial<ExtensionHostOptions> = {}): ExtensionHost {
return new ExtensionHost({
mode,
user: null,
provider,
model,
workspacePath: "/test/workspace",
extensionPath: "/test/extension",
...options,
})
}
// Type for accessing private members
type PrivateHost = Record<string, unknown>
/**
* Helper to access private members for testing
*/
function getPrivate<T>(host: ExtensionHost, key: string): T {
return (host as unknown as PrivateHost)[key] as T
}
/**
* Helper to call private methods for testing
*/
function callPrivate<T>(host: ExtensionHost, method: string, ...args: unknown[]): T {
const fn = (host as unknown as PrivateHost)[method] as ((...a: unknown[]) => T) | undefined
if (!fn) throw new Error(`Method ${method} not found`)
return fn.apply(host, args)
}
/**
* Helper to spy on private methods
* This uses a more permissive type to avoid TypeScript errors with vi.spyOn on private methods
*/
function spyOnPrivate(host: ExtensionHost, method: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return vi.spyOn(host as any, method)
}
describe("ExtensionHost", () => {
beforeEach(() => {
vi.resetAllMocks()
// Clean up globals
delete (global as Record<string, unknown>).vscode
delete (global as Record<string, unknown>).__extensionHost
})
describe("constructor", () => {
it("should store options correctly", () => {
const options: ExtensionHostOptions = {
mode: "code",
workspacePath: "/my/workspace",
extensionPath: "/my/extension",
user: null,
apiKey: "test-key",
provider: "openrouter",
model: "test-model",
}
const host = new ExtensionHost(options)
expect(getPrivate(host, "options")).toEqual(options)
})
it("should be an EventEmitter instance", () => {
const host = createTestHost()
expect(host).toBeInstanceOf(EventEmitter)
})
it("should initialize with default state values", () => {
const host = createTestHost()
expect(getPrivate(host, "isWebviewReady")).toBe(false)
expect(getPrivate<unknown[]>(host, "pendingMessages")).toEqual([])
expect(getPrivate(host, "vscode")).toBeNull()
expect(getPrivate(host, "extensionModule")).toBeNull()
})
it("should initialize managers", () => {
const host = createTestHost()
// Should have client, outputManager, promptManager, and askDispatcher
expect(getPrivate(host, "client")).toBeDefined()
expect(getPrivate(host, "outputManager")).toBeDefined()
expect(getPrivate(host, "promptManager")).toBeDefined()
expect(getPrivate(host, "askDispatcher")).toBeDefined()
})
})
describe("buildApiConfiguration", () => {
it.each([
[
"anthropic",
"test-key",
"test-model",
{ apiProvider: "anthropic", apiKey: "test-key", apiModelId: "test-model" },
],
[
"openrouter",
"or-key",
"or-model",
{
apiProvider: "openrouter",
openRouterApiKey: "or-key",
openRouterModelId: "or-model",
},
],
[
"gemini",
"gem-key",
"gem-model",
{ apiProvider: "gemini", geminiApiKey: "gem-key", apiModelId: "gem-model" },
],
[
"openai-native",
"oai-key",
"oai-model",
{ apiProvider: "openai-native", openAiNativeApiKey: "oai-key", apiModelId: "oai-model" },
],
[
"vercel-ai-gateway",
"vai-key",
"vai-model",
{
apiProvider: "vercel-ai-gateway",
vercelAiGatewayApiKey: "vai-key",
vercelAiGatewayModelId: "vai-model",
},
],
])("should configure %s provider correctly", (provider, apiKey, model, expected) => {
const host = createTestHost({
provider: provider as SupportedProvider,
apiKey,
model,
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config).toEqual(expected)
})
it("should use default provider when not specified", () => {
const host = createTestHost({
apiKey: "test-key",
model: "test-model",
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config.apiProvider).toBe("openrouter")
})
it("should handle missing apiKey gracefully", () => {
const host = createTestHost({
provider: "anthropic",
model: "test-model",
})
const config = callPrivate<Record<string, unknown>>(host, "buildApiConfiguration")
expect(config.apiProvider).toBe("anthropic")
expect(config.apiKey).toBeUndefined()
expect(config.apiModelId).toBe("test-model")
})
})
describe("webview provider registration", () => {
it("should register webview provider", () => {
const host = createTestHost()
const mockProvider = { resolveWebviewView: vi.fn() }
host.registerWebviewProvider("test-view", mockProvider)
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
expect(providers.get("test-view")).toBe(mockProvider)
})
it("should unregister webview provider", () => {
const host = createTestHost()
const mockProvider = { resolveWebviewView: vi.fn() }
host.registerWebviewProvider("test-view", mockProvider)
host.unregisterWebviewProvider("test-view")
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
expect(providers.has("test-view")).toBe(false)
})
it("should handle unregistering non-existent provider gracefully", () => {
const host = createTestHost()
expect(() => {
host.unregisterWebviewProvider("non-existent")
}).not.toThrow()
})
})
describe("webview ready state", () => {
describe("isInInitialSetup", () => {
it("should return true before webview is ready", () => {
const host = createTestHost()
expect(host.isInInitialSetup()).toBe(true)
})
it("should return false after markWebviewReady is called", () => {
const host = createTestHost()
host.markWebviewReady()
expect(host.isInInitialSetup()).toBe(false)
})
})
describe("markWebviewReady", () => {
it("should set isWebviewReady to true", () => {
const host = createTestHost()
host.markWebviewReady()
expect(getPrivate(host, "isWebviewReady")).toBe(true)
})
it("should emit webviewReady event", () => {
const host = createTestHost()
const listener = vi.fn()
host.on("webviewReady", listener)
host.markWebviewReady()
expect(listener).toHaveBeenCalled()
})
it("should flush pending messages", () => {
const host = createTestHost()
const emitSpy = vi.spyOn(host, "emit")
// Queue messages before ready
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: "requestModes" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
})
})
})
describe("sendToExtension", () => {
it("should queue message when webview not ready", () => {
const host = createTestHost()
const message: WebviewMessage = { type: "requestModes" }
host.sendToExtension(message)
const pending = getPrivate<unknown[]>(host, "pendingMessages")
expect(pending).toContain(message)
})
it("should emit webviewMessage event when webview is ready", () => {
const host = createTestHost()
const emitSpy = vi.spyOn(host, "emit")
const message: WebviewMessage = { type: "requestModes" }
host.markWebviewReady()
host.sendToExtension(message)
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", message)
})
it("should not queue message when webview is ready", () => {
const host = createTestHost()
host.markWebviewReady()
host.sendToExtension({ type: "requestModes" })
const pending = getPrivate<unknown[]>(host, "pendingMessages")
expect(pending).toHaveLength(0)
})
})
describe("handleExtensionMessage", () => {
it("should forward messages to the client", () => {
const host = createTestHost()
const client = host.getExtensionClient()
const handleMessageSpy = vi.spyOn(client, "handleMessage")
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
expect(handleMessageSpy).toHaveBeenCalled()
})
it("should track mode from state messages", () => {
const host = createTestHost()
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
expect(getPrivate(host, "currentMode")).toBe("architect")
})
it("should emit modesUpdated for modes messages", () => {
const host = createTestHost()
const emitSpy = vi.spyOn(host, "emit")
callPrivate(host, "handleExtensionMessage", { type: "modes", modes: [] })
expect(emitSpy).toHaveBeenCalledWith("modesUpdated", { type: "modes", modes: [] })
})
})
describe("public agent state API", () => {
it("should return agent state from getAgentState()", () => {
const host = createTestHost()
const state = host.getAgentState()
expect(state).toBeDefined()
expect(state.state).toBeDefined()
expect(state.isWaitingForInput).toBeDefined()
expect(state.isRunning).toBeDefined()
})
it("should return isWaitingForInput() status", () => {
const host = createTestHost()
expect(typeof host.isWaitingForInput()).toBe("boolean")
})
it("should return isAgentRunning() status", () => {
const host = createTestHost()
expect(typeof host.isAgentRunning()).toBe("boolean")
})
it("should return the client from getExtensionClient()", () => {
const host = createTestHost()
const client = host.getExtensionClient()
expect(client).toBeDefined()
expect(typeof client.handleMessage).toBe("function")
})
it("should return the output manager from getOutputManager()", () => {
const host = createTestHost()
const outputManager = host.getOutputManager()
expect(outputManager).toBeDefined()
expect(typeof outputManager.output).toBe("function")
})
it("should return the prompt manager from getPromptManager()", () => {
const host = createTestHost()
const promptManager = host.getPromptManager()
expect(promptManager).toBeDefined()
})
it("should return the ask dispatcher from getAskDispatcher()", () => {
const host = createTestHost()
const askDispatcher = host.getAskDispatcher()
expect(askDispatcher).toBeDefined()
expect(typeof askDispatcher.handleAsk).toBe("function")
})
})
describe("quiet mode", () => {
describe("setupQuietMode", () => {
it("should suppress console.log, warn, debug, info when enabled", () => {
const host = createTestHost()
const originalLog = console.log
callPrivate(host, "setupQuietMode")
// These should be no-ops now (different from original)
expect(console.log).not.toBe(originalLog)
// Verify they are actually no-ops by calling them (should not throw)
expect(() => console.log("test")).not.toThrow()
expect(() => console.warn("test")).not.toThrow()
expect(() => console.debug("test")).not.toThrow()
expect(() => console.info("test")).not.toThrow()
// Restore for other tests
callPrivate(host, "restoreConsole")
})
it("should preserve console.error", () => {
const host = createTestHost()
const originalError = console.error
callPrivate(host, "setupQuietMode")
expect(console.error).toBe(originalError)
callPrivate(host, "restoreConsole")
})
it("should store original console methods", () => {
const host = createTestHost()
const originalLog = console.log
callPrivate(host, "setupQuietMode")
const stored = getPrivate<{ log: typeof console.log }>(host, "originalConsole")
expect(stored.log).toBe(originalLog)
callPrivate(host, "restoreConsole")
})
})
describe("restoreConsole", () => {
it("should restore original console methods", () => {
const host = createTestHost()
const originalLog = console.log
callPrivate(host, "setupQuietMode")
callPrivate(host, "restoreConsole")
expect(console.log).toBe(originalLog)
})
it("should handle case where console was not suppressed", () => {
const host = createTestHost()
expect(() => {
callPrivate(host, "restoreConsole")
}).not.toThrow()
})
})
describe("suppressNodeWarnings", () => {
it("should suppress process.emitWarning", () => {
const host = createTestHost()
const originalEmitWarning = process.emitWarning
callPrivate(host, "suppressNodeWarnings")
expect(process.emitWarning).not.toBe(originalEmitWarning)
// Restore
callPrivate(host, "restoreConsole")
})
})
})
describe("dispose", () => {
let host: ExtensionHost
beforeEach(() => {
host = createTestHost()
})
it("should remove message listener", async () => {
const listener = vi.fn()
;(host as unknown as Record<string, unknown>).messageListener = listener
host.on("extensionWebviewMessage", listener)
await host.dispose()
expect(getPrivate(host, "messageListener")).toBeNull()
})
it("should call extension deactivate if available", async () => {
const deactivateMock = vi.fn()
;(host as unknown as Record<string, unknown>).extensionModule = {
deactivate: deactivateMock,
}
await host.dispose()
expect(deactivateMock).toHaveBeenCalled()
})
it("should clear vscode reference", async () => {
;(host as unknown as Record<string, unknown>).vscode = { context: {} }
await host.dispose()
expect(getPrivate(host, "vscode")).toBeNull()
})
it("should clear extensionModule reference", async () => {
;(host as unknown as Record<string, unknown>).extensionModule = {}
await host.dispose()
expect(getPrivate(host, "extensionModule")).toBeNull()
})
it("should clear webviewProviders", async () => {
host.registerWebviewProvider("test", {})
await host.dispose()
const providers = getPrivate<Map<string, unknown>>(host, "webviewProviders")
expect(providers.size).toBe(0)
})
it("should delete global vscode", async () => {
;(global as Record<string, unknown>).vscode = {}
await host.dispose()
expect((global as Record<string, unknown>).vscode).toBeUndefined()
})
it("should delete global __extensionHost", async () => {
;(global as Record<string, unknown>).__extensionHost = {}
await host.dispose()
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
})
it("should restore console if it was suppressed", async () => {
const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole")
await host.dispose()
expect(restoreConsoleSpy).toHaveBeenCalled()
})
it("should clear managers", async () => {
const outputManager = host.getOutputManager()
const askDispatcher = host.getAskDispatcher()
const outputClearSpy = vi.spyOn(outputManager, "clear")
const askClearSpy = vi.spyOn(askDispatcher, "clear")
await host.dispose()
expect(outputClearSpy).toHaveBeenCalled()
expect(askClearSpy).toHaveBeenCalled()
})
it("should reset client", async () => {
const client = host.getExtensionClient()
const resetSpy = vi.spyOn(client, "reset")
await host.dispose()
expect(resetSpy).toHaveBeenCalled()
})
})
describe("waitForCompletion", () => {
it("should resolve when taskComplete is emitted", async () => {
const host = createTestHost()
const promise = callPrivate<Promise<void>>(host, "waitForCompletion")
// Emit completion after a short delay
setTimeout(() => host.emit("taskComplete"), 10)
await expect(promise).resolves.toBeUndefined()
})
it("should reject when taskError is emitted", async () => {
const host = createTestHost()
const promise = callPrivate<Promise<void>>(host, "waitForCompletion")
setTimeout(() => host.emit("taskError", "Test error"), 10)
await expect(promise).rejects.toThrow("Test error")
})
})
describe("mode tracking via handleExtensionMessage", () => {
let host: ExtensionHost
beforeEach(() => {
host = createTestHost({
mode: "code",
provider: "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, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// Second state update should update tracked mode
callPrivate(host, "handleExtensionMessage", {
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, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// State without mode should not change tracked mode
callPrivate(host, "handleExtensionMessage", { type: "state", state: { clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
})
it("should track current mode across multiple changes", () => {
// Start with code mode
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// Change to architect
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
expect(getPrivate(host, "currentMode")).toBe("architect")
// Change to debug
callPrivate(host, "handleExtensionMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("debug")
// Another state update with debug
callPrivate(host, "handleExtensionMessage", { 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, "handleExtensionMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
sendToExtensionSpy.mockClear()
// Mode change should NOT trigger sendToExtension
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
expect(sendToExtensionSpy).not.toHaveBeenCalled()
})
})
describe("applyRuntimeSettings - mode switching", () => {
it("should use currentMode when set (from user mode switches)", () => {
const host = createTestHost({
mode: "code", // Initial mode from CLI options
provider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Simulate user switching mode via Ctrl+M - this updates currentMode
;(host as unknown as Record<string, unknown>).currentMode = "architect"
// Create settings object to be modified
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use currentMode (architect), not options.mode (code)
expect(settings.mode).toBe("architect")
})
it("should fall back to options.mode when currentMode is not set", () => {
const host = createTestHost({
mode: "code",
provider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// currentMode is not set (still null from constructor)
expect(getPrivate(host, "currentMode")).toBe("code") // Set from options.mode in constructor
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use options.mode as fallback
expect(settings.mode).toBe("code")
})
it("should use currentMode even when it differs from initial options.mode", () => {
const host = createTestHost({
mode: "code",
provider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Simulate multiple mode switches: code -> architect -> debug
;(host as unknown as Record<string, unknown>).currentMode = "debug"
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use the latest currentMode
expect(settings.mode).toBe("debug")
})
it("should not set mode if neither currentMode nor options.mode is set", () => {
const host = createTestHost({
// No mode specified - mode defaults to "code" in createTestHost
provider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Explicitly set currentMode to null (edge case)
;(host as unknown as Record<string, unknown>).currentMode = null
// Also clear options.mode
const options = getPrivate<ExtensionHostOptions>(host, "options")
options.mode = ""
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Mode should not be set
expect(settings.mode).toBeUndefined()
})
})
describe("mode switching - end to end simulation", () => {
let host: ExtensionHost
beforeEach(() => {
host = createTestHost({
mode: "code",
provider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should preserve mode switch when starting a new task", () => {
// Step 1: Initial state from extension (like webviewDidLaunch response)
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "code", clineMessages: [] },
})
expect(getPrivate(host, "currentMode")).toBe("code")
// Step 2: User presses Ctrl+M to switch mode, extension sends new state
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
expect(getPrivate(host, "currentMode")).toBe("architect")
// Step 3: When runTask is called, applyRuntimeSettings should use architect
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("architect")
})
it("should handle mode switch before any state messages", () => {
// currentMode is initialized to options.mode in constructor
expect(getPrivate(host, "currentMode")).toBe("code")
// Without any state messages, should still use options.mode
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("code")
})
it("should track multiple mode switches correctly", () => {
// Switch through multiple modes
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "code", clineMessages: [] },
})
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "debug", clineMessages: [] },
})
callPrivate(host, "handleExtensionMessage", {
type: "state",
state: { mode: "ask", clineMessages: [] },
})
// Should use the most recent mode
expect(getPrivate(host, "currentMode")).toBe("ask")
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("ask")
})
})
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

@ -1,46 +1,15 @@
/**
* Unit tests for CLI utility functions
*/
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
import fs from "fs"
import path from "path"
// Mock fs module
import { getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
vi.mock("fs")
describe("getEnvVarName", () => {
it.each([
["anthropic", "ANTHROPIC_API_KEY"],
["openai", "OPENAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
["google", "GOOGLE_API_KEY"],
["gemini", "GOOGLE_API_KEY"],
["bedrock", "AWS_ACCESS_KEY_ID"],
["ollama", "OLLAMA_API_KEY"],
["mistral", "MISTRAL_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
])("should return %s for %s provider", (provider, expectedEnvVar) => {
expect(getEnvVarName(provider)).toBe(expectedEnvVar)
})
it("should handle case-insensitive provider names", () => {
expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY")
expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY")
})
it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => {
expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY")
expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY")
})
})
describe("getApiKeyFromEnv", () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test
// Reset process.env before each test.
process.env = { ...originalEnv }
})
@ -60,28 +29,27 @@ describe("getApiKeyFromEnv", () => {
it("should return API key from environment variable for openai", () => {
process.env.OPENAI_API_KEY = "test-openai-key"
expect(getApiKeyFromEnv("openai")).toBe("test-openai-key")
expect(getApiKeyFromEnv("openai-native")).toBe("test-openai-key")
})
it("should return undefined when API key is not set", () => {
delete process.env.ANTHROPIC_API_KEY
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
})
it("should handle custom provider names", () => {
process.env.CUSTOM_API_KEY = "test-custom-key"
expect(getApiKeyFromEnv("custom")).toBe("test-custom-key")
})
it("should handle case-insensitive provider lookup", () => {
process.env.ANTHROPIC_API_KEY = "test-key"
expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key")
})
})
describe("getDefaultExtensionPath", () => {
const originalEnv = process.env
beforeEach(() => {
vi.resetAllMocks()
// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests.
process.env = { ...originalEnv }
delete process.env.ROO_EXTENSION_PATH
})
afterEach(() => {
process.env = originalEnv
})
it("should return monorepo path when extension.js exists there", () => {

View file

@ -0,0 +1,672 @@
/**
* AskDispatcher - Routes ask messages to appropriate handlers
*
* This dispatcher is responsible for:
* - Categorizing ask types using type guards from client module
* - Routing to the appropriate handler based on ask category
* - Coordinating between OutputManager and PromptManager
* - Tracking which asks have been handled (to avoid duplicates)
*
* Design notes:
* - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards
* - Single responsibility: Ask routing and handling only
* - Delegates output to OutputManager, input to PromptManager
* - Sends responses back through a provided callback
*/
import { debugLog } from "@roo-code/core/cli"
import type { WebviewMessage, ClineMessage, ClineAsk, ClineAskResponse } from "../extension-client/types.js"
import { isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "../extension-client/index.js"
import type { OutputManager } from "./output-manager.js"
import type { PromptManager } from "./prompt-manager.js"
import { FOLLOWUP_TIMEOUT_SECONDS } from "../types/constants.js"
// =============================================================================
// Types
// =============================================================================
/**
* Configuration for AskDispatcher.
*/
export interface AskDispatcherOptions {
/**
* OutputManager for displaying ask-related output.
*/
outputManager: OutputManager
/**
* PromptManager for collecting user input.
*/
promptManager: PromptManager
/**
* Callback to send responses to the extension.
*/
sendMessage: (message: WebviewMessage) => void
/**
* Whether running in non-interactive mode (auto-approve).
*/
nonInteractive?: boolean
/**
* Whether to disable ask handling (for TUI mode).
* In TUI mode, the TUI handles asks directly.
*/
disabled?: boolean
}
/**
* Result of handling an ask.
*/
export interface AskHandleResult {
/** Whether the ask was handled */
handled: boolean
/** The response sent (if any) */
response?: ClineAskResponse
/** Any error that occurred */
error?: Error
}
// =============================================================================
// AskDispatcher Class
// =============================================================================
export class AskDispatcher {
private outputManager: OutputManager
private promptManager: PromptManager
private sendMessage: (message: WebviewMessage) => void
private nonInteractive: boolean
private disabled: boolean
/**
* Track which asks have been handled to avoid duplicates.
* Key: message ts
*/
private handledAsks = new Set<number>()
constructor(options: AskDispatcherOptions) {
this.outputManager = options.outputManager
this.promptManager = options.promptManager
this.sendMessage = options.sendMessage
this.nonInteractive = options.nonInteractive ?? false
this.disabled = options.disabled ?? false
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Handle an ask message.
* Routes to the appropriate handler based on ask type.
*
* @param message - The ClineMessage with type="ask"
* @returns Promise<AskHandleResult>
*/
async handleAsk(message: ClineMessage): Promise<AskHandleResult> {
// Disabled in TUI mode - TUI handles asks directly
if (this.disabled) {
return { handled: false }
}
const ts = message.ts
const ask = message.ask
const text = message.text || ""
// Check if already handled
if (this.handledAsks.has(ts)) {
return { handled: true }
}
// Must be an ask message
if (message.type !== "ask" || !ask) {
return { handled: false }
}
// Skip partial messages (wait for complete)
if (message.partial) {
return { handled: false }
}
// Mark as being handled
this.handledAsks.add(ts)
try {
// Route based on ask category
if (isNonBlockingAsk(ask)) {
return await this.handleNonBlockingAsk(ts, ask, text)
}
if (isIdleAsk(ask)) {
return await this.handleIdleAsk(ts, ask, text)
}
if (isResumableAsk(ask)) {
return await this.handleResumableAsk(ts, ask, text)
}
if (isInteractiveAsk(ask)) {
return await this.handleInteractiveAsk(ts, ask, text)
}
// Unknown ask type - log and handle generically
debugLog("[AskDispatcher] Unknown ask type", { ask, ts })
return await this.handleUnknownAsk(ts, ask, text)
} catch (error) {
// Re-allow handling on error
this.handledAsks.delete(ts)
return {
handled: false,
error: error instanceof Error ? error : new Error(String(error)),
}
}
}
/**
* Check if an ask has been handled.
*/
isHandled(ts: number): boolean {
return this.handledAsks.has(ts)
}
/**
* Clear handled asks (call when starting new task).
*/
clear(): void {
this.handledAsks.clear()
}
// ===========================================================================
// Category Handlers
// ===========================================================================
/**
* Handle non-blocking asks (command_output).
* These don't actually block the agent - just need acknowledgment.
*/
private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise<AskHandleResult> {
// command_output - output is handled by OutputManager
// Just send approval to continue
this.sendApprovalResponse(true)
return { handled: true, response: "yesButtonClicked" }
}
/**
* Handle idle asks (completion_result, api_req_failed, etc.).
* These indicate the task has stopped.
*/
private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
switch (ask) {
case "completion_result":
// Task complete - nothing to do here, TaskCompleted event handles it
return { handled: true }
case "api_req_failed":
return await this.handleApiFailedRetry(ts, text)
case "mistake_limit_reached":
return await this.handleMistakeLimitReached(ts, text)
case "resume_completed_task":
return await this.handleResumeTask(ts, ask, text)
case "auto_approval_max_req_reached":
return await this.handleAutoApprovalMaxReached(ts, text)
default:
return { handled: false }
}
}
/**
* Handle resumable asks (resume_task).
*/
private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
return await this.handleResumeTask(ts, ask, text)
}
/**
* Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server).
* These require user approval or input.
*/
private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
switch (ask) {
case "followup":
return await this.handleFollowupQuestion(ts, text)
case "command":
return await this.handleCommandApproval(ts, text)
case "tool":
return await this.handleToolApproval(ts, text)
case "browser_action_launch":
return await this.handleBrowserApproval(ts, text)
case "use_mcp_server":
return await this.handleMcpApproval(ts, text)
default:
return { handled: false }
}
}
/**
* Handle unknown ask types.
*/
private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
if (this.nonInteractive) {
if (text) {
this.outputManager.output(`\n[${ask}]`, text)
}
return { handled: true }
}
return await this.handleGenericApproval(ts, ask, text)
}
// ===========================================================================
// Specific Ask Handlers
// ===========================================================================
/**
* Handle followup questions - prompt for text input with suggestions.
*/
private async handleFollowupQuestion(ts: number, text: string): Promise<AskHandleResult> {
let question = text
let suggestions: Array<{ answer: string; mode?: string | null }> = []
try {
const data = JSON.parse(text)
question = data.question || text
suggestions = Array.isArray(data.suggest) ? data.suggest : []
} catch {
// Use raw text if not JSON
}
this.outputManager.output("\n[question]", question)
if (suggestions.length > 0) {
this.outputManager.output("\nSuggested answers:")
suggestions.forEach((suggestion, index) => {
const suggestionText = suggestion.answer || String(suggestion)
const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : ""
this.outputManager.output(` ${index + 1}. ${suggestionText}${modeHint}`)
})
this.outputManager.output("")
}
const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null
const defaultAnswer = firstSuggestion?.answer ?? ""
if (this.nonInteractive) {
// Use timeout prompt in non-interactive mode
const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000
const result = await this.promptManager.promptWithTimeout(
suggestions.length > 0
? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `
: `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `,
timeoutMs,
defaultAnswer,
)
let responseText = result.value.trim()
responseText = this.resolveNumberedSuggestion(responseText, suggestions)
if (result.timedOut || result.cancelled) {
this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
}
this.sendFollowupResponse(responseText)
return { handled: true, response: "messageResponse" }
}
// Interactive mode
try {
const answer = await this.promptManager.promptForInput(
suggestions.length > 0
? `Enter number (1-${suggestions.length}) or type your answer: `
: "Your answer: ",
)
let responseText = answer.trim()
responseText = this.resolveNumberedSuggestion(responseText, suggestions)
this.sendFollowupResponse(responseText)
return { handled: true, response: "messageResponse" }
} catch {
this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`)
this.sendFollowupResponse(defaultAnswer)
return { handled: true, response: "messageResponse" }
}
}
/**
* Handle command execution approval.
*/
private async handleCommandApproval(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[command request]")
this.outputManager.output(` Command: ${text || "(no command specified)"}`)
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle tool execution approval.
*/
private async handleToolApproval(ts: number, text: string): Promise<AskHandleResult> {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
toolName = (toolInfo.tool as string) || "unknown"
} catch {
// Use raw text if not JSON
}
const isProtected = toolInfo.isProtected === true
if (isProtected) {
this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`)
this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`)
this.outputManager.output(
` Protected files include .rooignore, .roo/*, and other sensitive config files.`,
)
} else {
this.outputManager.output(`\n[Tool Request] ${toolName}`)
}
// Display tool details
for (const [key, value] of Object.entries(toolInfo)) {
if (key === "tool" || key === "isProtected") continue
let displayValue: string
if (typeof value === "string") {
displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value
} else if (typeof value === "object" && value !== null) {
const json = JSON.stringify(value)
displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json
} else {
displayValue = String(value)
}
this.outputManager.output(` ${key}: ${displayValue}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings (unless protected)
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle browser action approval.
*/
private async handleBrowserApproval(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[browser action request]")
if (text) {
this.outputManager.output(` Action: ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle MCP server access approval.
*/
private async handleMcpApproval(ts: number, text: string): Promise<AskHandleResult> {
let serverName = "unknown"
let toolName = ""
let resourceUri = ""
try {
const mcpInfo = JSON.parse(text)
serverName = mcpInfo.server_name || "unknown"
if (mcpInfo.type === "use_mcp_tool") {
toolName = mcpInfo.tool_name || ""
} else if (mcpInfo.type === "access_mcp_resource") {
resourceUri = mcpInfo.uri || ""
}
} catch {
// Use raw text if not JSON
}
this.outputManager.output("\n[mcp request]")
this.outputManager.output(` Server: ${serverName}`)
if (toolName) {
this.outputManager.output(` Tool: ${toolName}`)
}
if (resourceUri) {
this.outputManager.output(` Resource: ${resourceUri}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-approved by extension settings
return { handled: true }
}
try {
const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle API request failed - retry prompt.
*/
private async handleApiFailedRetry(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[api request failed]")
this.outputManager.output(` Error: ${text || "Unknown error"}`)
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
this.outputManager.output("\n[retrying api request]")
// Auto-retry in non-interactive mode
return { handled: true }
}
try {
const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ")
this.sendApprovalResponse(retry)
return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle mistake limit reached.
*/
private async handleMistakeLimitReached(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[mistake limit reached]")
if (text) {
this.outputManager.output(` Details: ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-proceed in non-interactive mode
this.sendApprovalResponse(true)
return { handled: true, response: "yesButtonClicked" }
}
try {
const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ")
this.sendApprovalResponse(proceed)
return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle auto-approval max reached.
*/
private async handleAutoApprovalMaxReached(ts: number, text: string): Promise<AskHandleResult> {
this.outputManager.output("\n[auto-approval limit reached]")
if (text) {
this.outputManager.output(` Details: ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
// Auto-proceed in non-interactive mode
this.sendApprovalResponse(true)
return { handled: true, response: "yesButtonClicked" }
}
try {
const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ")
this.sendApprovalResponse(proceed)
return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle task resume prompt.
*/
private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
const isCompleted = ask === "resume_completed_task"
this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`)
if (text) {
this.outputManager.output(` ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
if (this.nonInteractive) {
this.outputManager.output("\n[continuing task]")
// Auto-resume in non-interactive mode
this.sendApprovalResponse(true)
return { handled: true, response: "yesButtonClicked" }
}
try {
const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ")
this.sendApprovalResponse(resume)
return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
/**
* Handle generic approval prompts for unknown ask types.
*/
private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise<AskHandleResult> {
this.outputManager.output(`\n[${ask}]`)
if (text) {
this.outputManager.output(` ${text}`)
}
this.outputManager.markDisplayed(ts, text || "", false)
try {
const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ")
this.sendApprovalResponse(approved)
return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" }
} catch {
this.outputManager.output("[Defaulting to: no]")
this.sendApprovalResponse(false)
return { handled: true, response: "noButtonClicked" }
}
}
// ===========================================================================
// Response Helpers
// ===========================================================================
/**
* Send a followup response (text answer) to the extension.
*/
private sendFollowupResponse(text: string): void {
this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text })
}
/**
* Send an approval response (yes/no) to the extension.
*/
private sendApprovalResponse(approved: boolean): void {
this.sendMessage({
type: "askResponse",
askResponse: approved ? "yesButtonClicked" : "noButtonClicked",
})
}
/**
* Resolve a numbered suggestion selection.
*/
private resolveNumberedSuggestion(
input: string,
suggestions: Array<{ answer: string; mode?: string | null }>,
): string {
const num = parseInt(input, 10)
if (!isNaN(num) && num >= 1 && num <= suggestions.length) {
const selectedSuggestion = suggestions[num - 1]
if (selectedSuggestion) {
const selected = selectedSuggestion.answer || String(selectedSuggestion)
this.outputManager.output(`Selected: ${selected}`)
return selected
}
}
return input
}
}

View file

@ -0,0 +1,718 @@
/**
* ExtensionHost - Loads and runs the Roo Code extension in CLI mode
*
* This class is a thin coordination layer responsible for:
* 1. Creating the vscode-shim mock
* 2. Loading the extension bundle via require()
* 3. Activating the extension
* 4. Wiring up managers for output, prompting, and ask handling
*
* Managers handle all the heavy lifting:
* - ExtensionClient: Agent state detection (single source of truth)
* - OutputManager: CLI output and streaming
* - PromptManager: User input collection
* - AskDispatcher: Ask routing and handling
*/
import { EventEmitter } from "events"
import { createRequire } from "module"
import path from "path"
import { fileURLToPath } from "url"
import fs from "fs"
import os from "os"
import { ReasoningEffortExtended, RooCodeSettings, WebviewMessage } from "@roo-code/types"
import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim"
import { DebugLogger } from "@roo-code/core/cli"
import { SupportedProvider } from "../types/types.js"
import { User } from "../lib/sdk/types.js"
// Client module - single source of truth for agent state
import {
type AgentStateInfo,
type AgentStateChangeEvent,
type WaitingForInputEvent,
type TaskCompletedEvent,
type ClineMessage,
type ExtensionMessage,
ExtensionClient,
AgentLoopState,
} from "../extension-client/index.js"
// Managers for output, prompting, and ask handling
import { OutputManager } from "./output-manager.js"
import { PromptManager } from "./prompt-manager.js"
import { AskDispatcher } from "./ask-dispatcher.js"
// Pre-configured logger for CLI message activity debugging
const cliLogger = new DebugLogger("CLI")
// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep)
// When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script.
// In development, we fall back to calculating from __dirname.
// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level.
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..")
// =============================================================================
// Types
// =============================================================================
export interface ExtensionHostOptions {
mode: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
user: User | null
provider: SupportedProvider
apiKey?: string
model: string
workspacePath: string
extensionPath: string
nonInteractive?: boolean
debug?: boolean
/**
* When true, completely disables all direct stdout/stderr output.
* Use this when running in TUI mode where Ink controls the terminal.
*/
disableOutput?: boolean
/**
* When true, uses a temporary storage directory that is cleaned up on exit.
*/
ephemeral?: boolean
}
interface ExtensionModule {
activate: (context: unknown) => Promise<unknown>
deactivate?: () => Promise<void>
}
interface WebviewViewProvider {
resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise<void>
}
// =============================================================================
// ExtensionHost Class
// =============================================================================
export class ExtensionHost extends EventEmitter {
// Extension lifecycle
private vscode: ReturnType<typeof createVSCodeAPI> | null = null
private extensionModule: ExtensionModule | null = null
private extensionAPI: unknown = null
private webviewProviders: Map<string, WebviewViewProvider> = new Map()
private options: ExtensionHostOptions
private isWebviewReady = false
private pendingMessages: unknown[] = []
private messageListener: ((message: ExtensionMessage) => void) | null = null
// Console suppression
private originalConsole: {
log: typeof console.log
warn: typeof console.warn
error: typeof console.error
debug: typeof console.debug
info: typeof console.info
} | null = null
private originalProcessEmitWarning: typeof process.emitWarning | null = null
// Mode tracking
private currentMode: string | null = null
// Ephemeral storage
private ephemeralStorageDir: string | null = null
// ==========================================================================
// Managers - These do all the heavy lifting
// ==========================================================================
/**
* ExtensionClient: Single source of truth for agent loop state.
* Handles message processing and state detection.
*/
private client: ExtensionClient
/**
* OutputManager: Handles all CLI output and streaming.
* Uses Observable pattern internally for stream tracking.
*/
private outputManager: OutputManager
/**
* PromptManager: Handles all user input collection.
* Provides readline, yes/no, and timed prompts.
*/
private promptManager: PromptManager
/**
* AskDispatcher: Routes asks to appropriate handlers.
* Uses type guards (isIdleAsk, isInteractiveAsk, etc.) from client module.
*/
private askDispatcher: AskDispatcher
// ==========================================================================
// Constructor
// ==========================================================================
constructor(options: ExtensionHostOptions) {
super()
this.options = options
this.currentMode = options.mode || null
// Initialize client - single source of truth for agent state
this.client = new ExtensionClient({
sendMessage: (msg) => this.sendToExtension(msg),
debug: options.debug, // Enable debug logging in the client
})
// Initialize output manager
this.outputManager = new OutputManager({
disabled: options.disableOutput,
})
// Initialize prompt manager with console mode callbacks
this.promptManager = new PromptManager({
onBeforePrompt: () => this.restoreConsole(),
onAfterPrompt: () => this.setupQuietMode(),
})
// Initialize ask dispatcher
this.askDispatcher = new AskDispatcher({
outputManager: this.outputManager,
promptManager: this.promptManager,
sendMessage: (msg) => this.sendToExtension(msg),
nonInteractive: options.nonInteractive,
disabled: options.disableOutput, // TUI mode handles asks directly
})
// Wire up client events
this.setupClientEventHandlers()
}
// ==========================================================================
// Client Event Handlers
// ==========================================================================
/**
* Wire up client events to managers.
* The client emits events, managers handle them.
*/
private setupClientEventHandlers(): void {
// Forward state changes for external consumers
this.client.on("stateChange", (event: AgentStateChangeEvent) => {
this.emit("agentStateChange", event)
})
// Handle new messages - delegate to OutputManager
this.client.on("message", (msg: ClineMessage) => {
this.logMessageDebug(msg, "new")
this.outputManager.outputMessage(msg)
})
// Handle message updates - delegate to OutputManager
this.client.on("messageUpdated", (msg: ClineMessage) => {
this.logMessageDebug(msg, "updated")
this.outputManager.outputMessage(msg)
})
// Handle waiting for input - delegate to AskDispatcher
this.client.on("waitingForInput", (event: WaitingForInputEvent) => {
this.emit("agentWaitingForInput", event)
this.handleWaitingForInput(event)
})
// Handle task completion
this.client.on("taskCompleted", (event: TaskCompletedEvent) => {
this.emit("agentTaskCompleted", event)
this.handleTaskCompleted(event)
})
}
/**
* Debug logging for messages (first/last pattern).
*/
private logMessageDebug(msg: ClineMessage, type: "new" | "updated"): void {
if (msg.partial) {
if (!this.outputManager.hasLoggedFirstPartial(msg.ts)) {
this.outputManager.setLoggedFirstPartial(msg.ts)
cliLogger.debug("message:start", { ts: msg.ts, type: msg.say || msg.ask })
}
} else {
cliLogger.debug(`message:${type === "new" ? "new" : "complete"}`, { ts: msg.ts, type: msg.say || msg.ask })
this.outputManager.clearLoggedFirstPartial(msg.ts)
}
}
/**
* Handle waiting for input - delegate to AskDispatcher.
*/
private handleWaitingForInput(event: WaitingForInputEvent): void {
// AskDispatcher handles all ask logic
this.askDispatcher.handleAsk(event.message)
}
/**
* Handle task completion.
*/
private handleTaskCompleted(event: TaskCompletedEvent): void {
// Output completion message via OutputManager
// Note: completion_result is an "ask" type, not a "say" type
if (event.message && event.message.type === "ask" && event.message.ask === "completion_result") {
this.outputManager.outputCompletionResult(event.message.ts, event.message.text || "")
}
// Emit taskComplete for waitForCompletion
this.emit("taskComplete")
}
// ==========================================================================
// Console Suppression
// ==========================================================================
private suppressNodeWarnings(): void {
this.originalProcessEmitWarning = process.emitWarning
process.emitWarning = () => {}
process.on("warning", () => {})
}
private setupQuietMode(): void {
this.originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
debug: console.debug,
info: console.info,
}
console.log = () => {}
console.warn = () => {}
console.debug = () => {}
console.info = () => {}
}
private restoreConsole(): void {
if (this.originalConsole) {
console.log = this.originalConsole.log
console.warn = this.originalConsole.warn
console.error = this.originalConsole.error
console.debug = this.originalConsole.debug
console.info = this.originalConsole.info
this.originalConsole = null
}
if (this.originalProcessEmitWarning) {
process.emitWarning = this.originalProcessEmitWarning
this.originalProcessEmitWarning = null
}
}
// ==========================================================================
// Extension Lifecycle
// ==========================================================================
private async createEphemeralStorageDir(): Promise<string> {
const uniqueId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
const tmpDir = path.join(os.tmpdir(), `roo-cli-${uniqueId}`)
await fs.promises.mkdir(tmpDir, { recursive: true })
return tmpDir
}
async activate(): Promise<void> {
this.suppressNodeWarnings()
this.setupQuietMode()
const bundlePath = path.join(this.options.extensionPath, "extension.js")
if (!fs.existsSync(bundlePath)) {
this.restoreConsole()
throw new Error(`Extension bundle not found at: ${bundlePath}`)
}
let storageDir: string | undefined
if (this.options.ephemeral) {
storageDir = await this.createEphemeralStorageDir()
this.ephemeralStorageDir = storageDir
}
// Create VSCode API mock
this.vscode = createVSCodeAPI(this.options.extensionPath, this.options.workspacePath, undefined, {
appRoot: CLI_PACKAGE_ROOT,
storageDir,
})
;(global as Record<string, unknown>).vscode = this.vscode
;(global as Record<string, unknown>).__extensionHost = this
// Set up module resolution
const require = createRequire(import.meta.url)
const Module = require("module")
const originalResolve = Module._resolveFilename
Module._resolveFilename = function (request: string, parent: unknown, isMain: boolean, options: unknown) {
if (request === "vscode") return "vscode-mock"
return originalResolve.call(this, request, parent, isMain, options)
}
require.cache["vscode-mock"] = {
id: "vscode-mock",
filename: "vscode-mock",
loaded: true,
exports: this.vscode,
children: [],
paths: [],
path: "",
isPreloading: false,
parent: null,
require: require,
} as unknown as NodeJS.Module
try {
this.extensionModule = require(bundlePath) as ExtensionModule
} catch (error) {
Module._resolveFilename = originalResolve
throw new Error(
`Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`,
)
}
Module._resolveFilename = originalResolve
try {
this.extensionAPI = await this.extensionModule.activate(this.vscode.context)
} catch (error) {
throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`)
}
// Set up message listener - forward all messages to client
this.messageListener = (message: ExtensionMessage) => this.handleExtensionMessage(message)
this.on("extensionWebviewMessage", this.messageListener)
}
// ==========================================================================
// Webview Provider Registration
// ==========================================================================
registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void {
this.webviewProviders.set(viewId, provider)
}
unregisterWebviewProvider(viewId: string): void {
this.webviewProviders.delete(viewId)
}
isInInitialSetup(): boolean {
return !this.isWebviewReady
}
markWebviewReady(): void {
this.isWebviewReady = true
this.emit("webviewReady")
this.flushPendingMessages()
}
private flushPendingMessages(): void {
if (this.pendingMessages.length > 0) {
for (const message of this.pendingMessages) {
this.emit("webviewMessage", message)
}
this.pendingMessages = []
}
}
// ==========================================================================
// Message Handling
// ==========================================================================
sendToExtension(message: WebviewMessage): void {
if (!this.isWebviewReady) {
this.pendingMessages.push(message)
return
}
this.emit("webviewMessage", message)
}
/**
* Handle incoming messages from extension.
* Forward to client (single source of truth).
*/
private handleExtensionMessage(msg: ExtensionMessage): void {
// Track mode changes
if (msg.type === "state" && msg.state?.mode && typeof msg.state.mode === "string") {
this.currentMode = msg.state.mode
}
// Forward to client - it's the single source of truth
this.client.handleMessage(msg)
// Handle modes separately
if (msg.type === "modes") {
this.emit("modesUpdated", msg)
}
}
// ==========================================================================
// Task Management
// ==========================================================================
private applyRuntimeSettings(settings: RooCodeSettings): void {
const activeMode = this.currentMode || this.options.mode
if (activeMode) {
settings.mode = activeMode
}
if (this.options.reasoningEffort && this.options.reasoningEffort !== "unspecified") {
if (this.options.reasoningEffort === "disabled") {
settings.enableReasoningEffort = false
} else {
settings.enableReasoningEffort = true
settings.reasoningEffort = this.options.reasoningEffort
}
}
setRuntimeConfigValues("roo-cline", settings as Record<string, unknown>)
}
private getApiKeyFromEnv(provider: string): string | undefined {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
"openai-native": "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
xai: "XAI_API_KEY",
groq: "GROQ_API_KEY",
}
const envVar = envVarMap[provider.toLowerCase()] || `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`
return process.env[envVar]
}
private buildApiConfiguration(): RooCodeSettings {
const provider = this.options.provider
const apiKey = this.options.apiKey || this.getApiKeyFromEnv(provider)
const model = this.options.model
const config: RooCodeSettings = { apiProvider: provider }
switch (provider) {
case "anthropic":
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
break
case "openai-native":
if (apiKey) config.openAiNativeApiKey = apiKey
if (model) config.apiModelId = model
break
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model
break
case "openrouter":
if (apiKey) config.openRouterApiKey = apiKey
if (model) config.openRouterModelId = model
break
case "vercel-ai-gateway":
if (apiKey) config.vercelAiGatewayApiKey = apiKey
if (model) config.vercelAiGatewayModelId = model
break
case "roo":
if (apiKey) config.rooApiKey = apiKey
if (model) config.apiModelId = model
break
default:
if (apiKey) config.apiKey = apiKey
if (model) config.apiModelId = model
}
return config
}
async runTask(prompt: string): Promise<void> {
if (!this.isWebviewReady) {
await new Promise<void>((resolve) => this.once("webviewReady", resolve))
}
// Send initial webview messages to trigger proper extension initialization
// This is critical for the extension to start sending state updates properly
this.sendToExtension({ type: "webviewDidLaunch" })
const baseSettings: RooCodeSettings = {
commandExecutionTimeout: 30,
browserToolEnabled: false,
enableCheckpoints: false,
...this.buildApiConfiguration(),
}
const settings: RooCodeSettings = this.options.nonInteractive
? {
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: true,
alwaysAllowWrite: true,
alwaysAllowWriteOutsideWorkspace: true,
alwaysAllowWriteProtected: true,
alwaysAllowBrowser: true,
alwaysAllowMcp: true,
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
alwaysAllowExecute: true,
allowedCommands: ["*"],
...baseSettings,
}
: {
autoApprovalEnabled: false,
...baseSettings,
}
this.applyRuntimeSettings(settings)
this.sendToExtension({ type: "updateSettings", updatedSettings: settings })
await new Promise<void>((resolve) => setTimeout(resolve, 100))
this.sendToExtension({ type: "newTask", text: prompt })
await this.waitForCompletion()
}
private waitForCompletion(timeoutMs: number = 110000): Promise<void> {
return new Promise((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null
const completeHandler = () => {
cleanup()
resolve()
}
const errorHandler = (error: string) => {
cleanup()
reject(new Error(error))
}
const timeoutHandler = () => {
cleanup()
reject(
new Error(`Task completion timeout after ${timeoutMs}ms - no completion or error event received`),
)
}
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId)
timeoutId = null
}
this.off("taskComplete", completeHandler)
this.off("taskError", errorHandler)
}
// Set timeout to prevent indefinite hanging
timeoutId = setTimeout(timeoutHandler, timeoutMs)
this.once("taskComplete", completeHandler)
this.once("taskError", errorHandler)
})
}
// ==========================================================================
// Public Agent State API (delegated to ExtensionClient)
// ==========================================================================
/**
* Get the current agent loop state.
*/
getAgentState(): AgentStateInfo {
return this.client.getAgentState()
}
/**
* Check if the agent is currently waiting for user input.
*/
isWaitingForInput(): boolean {
return this.client.getAgentState().isWaitingForInput
}
/**
* Check if the agent is currently running.
*/
isAgentRunning(): boolean {
return this.client.getAgentState().isRunning
}
/**
* Get the current agent loop state enum value.
*/
getAgentLoopState(): AgentLoopState {
return this.client.getAgentState().state
}
/**
* Get the underlying ExtensionClient for advanced use cases.
*/
getExtensionClient(): ExtensionClient {
return this.client
}
/**
* Get the OutputManager for advanced output control.
*/
getOutputManager(): OutputManager {
return this.outputManager
}
/**
* Get the PromptManager for advanced prompting.
*/
getPromptManager(): PromptManager {
return this.promptManager
}
/**
* Get the AskDispatcher for advanced ask handling.
*/
getAskDispatcher(): AskDispatcher {
return this.askDispatcher
}
// ==========================================================================
// Cleanup
// ==========================================================================
async dispose(): Promise<void> {
// Clear managers
this.outputManager.clear()
this.askDispatcher.clear()
// Remove message listener
if (this.messageListener) {
this.off("extensionWebviewMessage", this.messageListener)
this.messageListener = null
}
// Reset client
this.client.reset()
// Deactivate extension
if (this.extensionModule?.deactivate) {
try {
await this.extensionModule.deactivate()
} catch {
// NO-OP
}
}
// Clear references
this.vscode = null
this.extensionModule = null
this.extensionAPI = null
this.webviewProviders.clear()
// Clear globals
delete (global as Record<string, unknown>).vscode
delete (global as Record<string, unknown>).__extensionHost
// Restore console
this.restoreConsole()
// Clean up ephemeral storage
if (this.ephemeralStorageDir) {
try {
await fs.promises.rm(this.ephemeralStorageDir, { recursive: true, force: true })
this.ephemeralStorageDir = null
} catch {
// NO-OP
}
}
}
}

View file

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

View file

@ -0,0 +1,413 @@
/**
* OutputManager - Handles all CLI output and streaming
*
* This manager is responsible for:
* - Writing messages to stdout/stderr
* - Tracking what's been displayed (to avoid duplicates)
* - Managing streaming content with delta computation
* - Formatting different message types appropriately
*
* Design notes:
* - Uses the Observable pattern from client/events.ts for internal state
* - Single responsibility: CLI output only (no prompting, no state detection)
* - Can be disabled for TUI mode where Ink controls the terminal
*/
import { Observable } from "../extension-client/events.js"
import type { ClineMessage, ClineSay } from "../extension-client/types.js"
// =============================================================================
// Types
// =============================================================================
/**
* Tracks what we've displayed for a specific message ts.
*/
export interface DisplayedMessage {
ts: number
text: string
partial: boolean
}
/**
* Tracks streaming state for a message.
*/
export interface StreamState {
ts: number
text: string
headerShown: boolean
}
/**
* Configuration options for OutputManager.
*/
export interface OutputManagerOptions {
/**
* When true, completely disables all output.
* Use for TUI mode where another system controls the terminal.
*/
disabled?: boolean
/**
* Stream for normal output (default: process.stdout).
*/
stdout?: NodeJS.WriteStream
/**
* Stream for error output (default: process.stderr).
*/
stderr?: NodeJS.WriteStream
}
// =============================================================================
// OutputManager Class
// =============================================================================
export class OutputManager {
private disabled: boolean
private stdout: NodeJS.WriteStream
private stderr: NodeJS.WriteStream
/**
* Track displayed messages by ts to avoid duplicate output.
* Observable pattern allows external systems to subscribe if needed.
*/
private displayedMessages = new Map<number, DisplayedMessage>()
/**
* Track streamed content by ts for delta computation.
*/
private streamedContent = new Map<number, StreamState>()
/**
* Track which ts is currently streaming (for newline management).
*/
private currentlyStreamingTs: number | null = null
/**
* Track first partial logs (for debugging first/last pattern).
*/
private loggedFirstPartial = new Set<number>()
/**
* Observable for streaming state changes.
* External systems can subscribe to know when streaming starts/ends.
*/
public readonly streamingState = new Observable<{ ts: number | null; isStreaming: boolean }>({
ts: null,
isStreaming: false,
})
constructor(options: OutputManagerOptions = {}) {
this.disabled = options.disabled ?? false
this.stdout = options.stdout ?? process.stdout
this.stderr = options.stderr ?? process.stderr
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Output a ClineMessage based on its type.
* This is the main entry point for message output.
*
* @param msg - The message to output
* @param skipFirstUserMessage - If true, skip the first "text" message (user prompt echo)
*/
outputMessage(msg: ClineMessage, skipFirstUserMessage = true): void {
const ts = msg.ts
const text = msg.text || ""
const isPartial = msg.partial === true
const previousDisplay = this.displayedMessages.get(ts)
const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial
if (msg.type === "say" && msg.say) {
this.outputSayMessage(ts, msg.say, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
} else if (msg.type === "ask" && msg.ask) {
// For ask messages, we only output command_output here
// Other asks are handled by AskDispatcher
if (msg.ask === "command_output") {
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
}
}
}
/**
* Output a simple text line with a label.
*/
output(label: string, text?: string): void {
if (this.disabled) return
const message = text ? `${label} ${text}\n` : `${label}\n`
this.stdout.write(message)
}
/**
* Output an error message.
*/
outputError(label: string, text?: string): void {
if (this.disabled) return
const message = text ? `${label} ${text}\n` : `${label}\n`
this.stderr.write(message)
}
/**
* Write raw text to stdout (for streaming).
*/
writeRaw(text: string): void {
if (this.disabled) return
this.stdout.write(text)
}
/**
* Check if a message has already been fully displayed.
*/
isAlreadyDisplayed(ts: number): boolean {
const displayed = this.displayedMessages.get(ts)
return displayed !== undefined && !displayed.partial
}
/**
* Check if we're currently streaming any message.
*/
isCurrentlyStreaming(): boolean {
return this.currentlyStreamingTs !== null
}
/**
* Get the ts of the currently streaming message.
*/
getCurrentlyStreamingTs(): number | null {
return this.currentlyStreamingTs
}
/**
* Mark a message as displayed (useful for external coordination).
*/
markDisplayed(ts: number, text: string, partial: boolean): void {
this.displayedMessages.set(ts, { ts, text, partial })
}
/**
* Clear all tracking state.
* Call this when starting a new task.
*/
clear(): void {
this.displayedMessages.clear()
this.streamedContent.clear()
this.currentlyStreamingTs = null
this.loggedFirstPartial.clear()
this.streamingState.next({ ts: null, isStreaming: false })
}
/**
* Get debugging info about first partial logging.
*/
hasLoggedFirstPartial(ts: number): boolean {
return this.loggedFirstPartial.has(ts)
}
/**
* Record that we've logged the first partial for a ts.
*/
setLoggedFirstPartial(ts: number): void {
this.loggedFirstPartial.add(ts)
}
/**
* Clear the first partial record (when complete).
*/
clearLoggedFirstPartial(ts: number): void {
this.loggedFirstPartial.delete(ts)
}
// ===========================================================================
// Say Message Output
// ===========================================================================
private outputSayMessage(
ts: number,
say: ClineSay,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
skipFirstUserMessage: boolean,
): void {
switch (say) {
case "text":
this.outputTextMessage(ts, text, isPartial, alreadyDisplayedComplete, skipFirstUserMessage)
break
// case "thinking": - not a valid ClineSay type
case "reasoning":
this.outputReasoningMessage(ts, text, isPartial, alreadyDisplayedComplete)
break
case "command_output":
this.outputCommandOutput(ts, text, isPartial, alreadyDisplayedComplete)
break
// Note: completion_result is an "ask" type, not a "say" type.
// It is handled via the TaskCompleted event in extension-host.ts
case "error":
if (!alreadyDisplayedComplete) {
this.outputError("\n[error]", text || "Unknown error")
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
}
break
case "api_req_started":
// Silent - no output needed
break
default:
// NO-OP for unknown say types
break
}
}
private outputTextMessage(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
skipFirstUserMessage: boolean,
): void {
// Skip the initial user prompt echo (first message with no prior messages)
if (skipFirstUserMessage && this.displayedMessages.size === 0 && !this.displayedMessages.has(ts)) {
this.displayedMessages.set(ts, { ts, text, partial: !!isPartial })
return
}
if (isPartial && text) {
// Stream partial content
this.streamContent(ts, text, "[assistant]")
this.displayedMessages.set(ts, { ts, text, partial: true })
} else if (!isPartial && text && !alreadyDisplayedComplete) {
// Message complete - ensure all content is output
const streamed = this.streamedContent.get(ts)
if (streamed) {
// We were streaming - output any remaining delta and finish
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
const delta = text.slice(streamed.text.length)
this.writeRaw(delta)
}
this.finishStream(ts)
} else {
// Not streamed yet - output complete message
this.output("\n[assistant]", text)
}
this.displayedMessages.set(ts, { ts, text, partial: false })
this.streamedContent.set(ts, { ts, text, headerShown: true })
}
}
private outputReasoningMessage(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
if (isPartial && text) {
this.streamContent(ts, text, "[reasoning]")
this.displayedMessages.set(ts, { ts, text, partial: true })
} else if (!isPartial && text && !alreadyDisplayedComplete) {
// Reasoning complete - finish the stream
const streamed = this.streamedContent.get(ts)
if (streamed) {
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
const delta = text.slice(streamed.text.length)
this.writeRaw(delta)
}
this.finishStream(ts)
} else {
this.output("\n[reasoning]", text)
}
this.displayedMessages.set(ts, { ts, text, partial: false })
}
}
/**
* Output command_output (shared between say and ask types).
*/
outputCommandOutput(
ts: number,
text: string,
isPartial: boolean,
alreadyDisplayedComplete: boolean | undefined,
): void {
if (isPartial && text) {
this.streamContent(ts, text, "[command output]")
this.displayedMessages.set(ts, { ts, text, partial: true })
} else if (!isPartial && text && !alreadyDisplayedComplete) {
const streamed = this.streamedContent.get(ts)
if (streamed) {
if (text.length > streamed.text.length && text.startsWith(streamed.text)) {
const delta = text.slice(streamed.text.length)
this.writeRaw(delta)
}
this.finishStream(ts)
} else {
this.writeRaw("\n[command output] ")
this.writeRaw(text)
this.writeRaw("\n")
}
this.displayedMessages.set(ts, { ts, text, partial: false })
this.streamedContent.set(ts, { ts, text, headerShown: true })
}
}
// ===========================================================================
// Streaming Helpers
// ===========================================================================
/**
* Stream content with delta computation - only output new characters.
*/
streamContent(ts: number, text: string, header: string): void {
const previous = this.streamedContent.get(ts)
if (!previous) {
// First time seeing this message - output header and initial text
this.writeRaw(`\n${header} `)
this.writeRaw(text)
this.streamedContent.set(ts, { ts, text, headerShown: true })
this.currentlyStreamingTs = ts
this.streamingState.next({ ts, isStreaming: true })
} else if (text.length > previous.text.length && text.startsWith(previous.text)) {
// Text has grown - output delta
const delta = text.slice(previous.text.length)
this.writeRaw(delta)
this.streamedContent.set(ts, { ts, text, headerShown: true })
}
}
/**
* Finish streaming a message (add newline).
*/
finishStream(ts: number): void {
if (this.currentlyStreamingTs === ts) {
this.writeRaw("\n")
this.currentlyStreamingTs = null
this.streamingState.next({ ts: null, isStreaming: false })
}
}
/**
* Output completion message (called from TaskCompleted handler).
*/
outputCompletionResult(ts: number, text: string): void {
const previousDisplay = this.displayedMessages.get(ts)
if (!previousDisplay || previousDisplay.partial) {
this.output("\n[task complete]", text || "")
this.displayedMessages.set(ts, { ts, text: text || "", partial: false })
}
}
}

View file

@ -0,0 +1,297 @@
/**
* PromptManager - Handles all user input collection
*
* This manager is responsible for:
* - Collecting user input via readline
* - Yes/No prompts with proper defaults
* - Timed prompts that auto-select after timeout
* - Raw mode input for character-by-character handling
*
* Design notes:
* - Single responsibility: User input only (no output formatting)
* - Returns Promises for all input operations
* - Handles console mode switching (quiet mode restore)
* - Can be disabled for programmatic (non-interactive) use
*/
import readline from "readline"
// =============================================================================
// Types
// =============================================================================
/**
* Configuration options for PromptManager.
*/
export interface PromptManagerOptions {
/**
* Called before prompting to restore console output.
* Used to exit quiet mode temporarily.
*/
onBeforePrompt?: () => void
/**
* Called after prompting to re-enable quiet mode.
*/
onAfterPrompt?: () => void
/**
* Stream for input (default: process.stdin).
*/
stdin?: NodeJS.ReadStream
/**
* Stream for prompt output (default: process.stdout).
*/
stdout?: NodeJS.WriteStream
}
/**
* Result of a timed prompt.
*/
export interface TimedPromptResult {
/** The user's input, or default if timed out */
value: string
/** Whether the result came from timeout */
timedOut: boolean
/** Whether the user cancelled (Ctrl+C) */
cancelled: boolean
}
// =============================================================================
// PromptManager Class
// =============================================================================
export class PromptManager {
private onBeforePrompt?: () => void
private onAfterPrompt?: () => void
private stdin: NodeJS.ReadStream
private stdout: NodeJS.WriteStream
/**
* Track if a prompt is currently active.
*/
private isPrompting = false
constructor(options: PromptManagerOptions = {}) {
this.onBeforePrompt = options.onBeforePrompt
this.onAfterPrompt = options.onAfterPrompt
this.stdin = options.stdin ?? (process.stdin as NodeJS.ReadStream)
this.stdout = options.stdout ?? process.stdout
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Check if a prompt is currently active.
*/
isActive(): boolean {
return this.isPrompting
}
/**
* Prompt for text input using readline.
*
* @param prompt - The prompt text to display
* @returns The user's input
* @throws If input is cancelled or an error occurs
*/
async promptForInput(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
this.beforePrompt()
this.isPrompting = true
const rl = readline.createInterface({
input: this.stdin,
output: this.stdout,
})
rl.question(prompt, (answer) => {
rl.close()
this.isPrompting = false
this.afterPrompt()
resolve(answer)
})
rl.on("close", () => {
this.isPrompting = false
this.afterPrompt()
})
rl.on("error", (err) => {
rl.close()
this.isPrompting = false
this.afterPrompt()
reject(err)
})
})
}
/**
* Prompt for yes/no input.
*
* @param prompt - The prompt text to display
* @param defaultValue - Default value if empty input (default: false)
* @returns true for yes, false for no
*/
async promptForYesNo(prompt: string, defaultValue = false): Promise<boolean> {
const answer = await this.promptForInput(prompt)
const normalized = answer.trim().toLowerCase()
if (normalized === "" && defaultValue !== undefined) {
return defaultValue
}
return normalized === "y" || normalized === "yes"
}
/**
* Prompt for input with a timeout.
* Uses raw mode for character-by-character input handling.
*
* @param prompt - The prompt text to display
* @param timeoutMs - Timeout in milliseconds
* @param defaultValue - Value to use if timed out
* @returns TimedPromptResult with value, timedOut flag, and cancelled flag
*/
async promptWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise<TimedPromptResult> {
return new Promise((resolve) => {
this.beforePrompt()
this.isPrompting = true
// Track the original raw mode state to restore it later
const wasRaw = this.stdin.isRaw
// Enable raw mode for character-by-character input if TTY
if (this.stdin.isTTY) {
this.stdin.setRawMode(true)
}
this.stdin.resume()
let inputBuffer = ""
let timeoutCancelled = false
let resolved = false
// Set up timeout
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
cleanup()
this.stdout.write(`\n[Timeout - using default: ${defaultValue || "(empty)"}]\n`)
resolve({ value: defaultValue, timedOut: true, cancelled: false })
}
}, timeoutMs)
// Display prompt
this.stdout.write(prompt)
// Cleanup function to restore state
const cleanup = () => {
clearTimeout(timeout)
this.stdin.removeListener("data", onData)
if (this.stdin.isTTY && wasRaw !== undefined) {
this.stdin.setRawMode(wasRaw)
}
this.stdin.pause()
this.isPrompting = false
this.afterPrompt()
}
// Handle incoming data
const onData = (data: Buffer) => {
const char = data.toString()
// Handle Ctrl+C
if (char === "\x03") {
cleanup()
resolved = true
this.stdout.write("\n[cancelled]\n")
resolve({ value: defaultValue, timedOut: false, cancelled: true })
return
}
// Cancel timeout on first input
if (!timeoutCancelled) {
timeoutCancelled = true
clearTimeout(timeout)
}
// Handle Enter
if (char === "\r" || char === "\n") {
if (!resolved) {
resolved = true
cleanup()
this.stdout.write("\n")
resolve({ value: inputBuffer, timedOut: false, cancelled: false })
}
return
}
// Handle Backspace
if (char === "\x7f" || char === "\b") {
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1)
this.stdout.write("\b \b")
}
return
}
// Normal character - add to buffer and echo
inputBuffer += char
this.stdout.write(char)
}
this.stdin.on("data", onData)
})
}
/**
* Prompt for yes/no with timeout.
*
* @param prompt - The prompt text to display
* @param timeoutMs - Timeout in milliseconds
* @param defaultValue - Default boolean value if timed out
* @returns true for yes, false for no
*/
async promptForYesNoWithTimeout(prompt: string, timeoutMs: number, defaultValue: boolean): Promise<boolean> {
const result = await this.promptWithTimeout(prompt, timeoutMs, defaultValue ? "y" : "n")
const normalized = result.value.trim().toLowerCase()
if (result.timedOut || result.cancelled || normalized === "") {
return defaultValue
}
return normalized === "y" || normalized === "yes"
}
/**
* Display a message on stdout (utility for prompting context).
*/
write(text: string): void {
this.stdout.write(text)
}
/**
* Display a message with newline.
*/
writeLine(text: string): void {
this.stdout.write(text + "\n")
}
// ===========================================================================
// Private Helpers
// ===========================================================================
private beforePrompt(): void {
if (this.onBeforePrompt) {
this.onBeforePrompt()
}
}
private afterPrompt(): void {
if (this.onAfterPrompt) {
this.onAfterPrompt()
}
}
}

View file

@ -1,32 +1,24 @@
/**
* Utility functions for the Roo Code CLI
*/
import path from "path"
import fs from "fs"
/**
* Get the environment variable name for a provider's API key
*/
export function getEnvVarName(provider: string): string {
const envVarMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
google: "GOOGLE_API_KEY",
gemini: "GOOGLE_API_KEY",
bedrock: "AWS_ACCESS_KEY_ID",
ollama: "OLLAMA_API_KEY",
mistral: "MISTRAL_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
}
return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY`
import type { SupportedProvider } from "../types/types.js"
const envVarMap: Record<SupportedProvider, string> = {
// Frontier Labs
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
gemini: "GOOGLE_API_KEY",
// Routers
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
roo: "ROO_API_KEY",
}
/**
* Get API key from environment variable based on provider
*/
export function getApiKeyFromEnv(provider: string): string | undefined {
export function getEnvVarName(provider: SupportedProvider): string {
return envVarMap[provider]
}
export function getApiKeyFromEnv(provider: SupportedProvider): string | undefined {
const envVar = getEnvVarName(provider)
return process.env[envVar]
}
@ -41,6 +33,7 @@ export function getDefaultExtensionPath(dirname: string): string {
// Check for environment variable first (set by install script)
if (process.env.ROO_EXTENSION_PATH) {
const envPath = process.env.ROO_EXTENSION_PATH
if (fs.existsSync(path.join(envPath, "extension.js"))) {
return envPath
}

View file

@ -1,163 +1,66 @@
/**
* @roo-code/cli - Command Line Interface for Roo Code
*/
import { Command } from "commander"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import {
type ProviderName,
type ReasoningEffortExtended,
isProviderName,
reasoningEffortsExtended,
} from "@roo-code/types"
import { setLogger } from "@roo-code/vscode-shim"
import { DEFAULT_FLAGS } from "./types/constants.js"
import { ExtensionHost } from "./extension-host.js"
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
const DEFAULTS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
}
const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
const __dirname = path.dirname(fileURLToPath(import.meta.url))
import { run, login, logout, status } from "./commands/index.js"
import { VERSION } from "./lib/utils/version.js"
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(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)
.option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false)
.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)")
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)")
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
.option("-m, --model <model>", "Model to use", DEFAULTS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode)
.option("-m, --model <model>", "Model to use", DEFAULT_FLAGS.model)
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode)
.option(
"-r, --reasoning-effort <effort>",
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
DEFAULTS.reasoningEffort,
DEFAULT_FLAGS.reasoningEffort,
)
.action(
async (
prompt: string,
options: {
workspace: string
extension?: string
verbose: boolean
debug: boolean
exitOnComplete: boolean
yes: boolean
apiKey?: string
provider: ProviderName
model?: string
mode?: string
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
},
) => {
// Default is quiet mode - suppress VSCode shim logs unless verbose
// or debug is specified.
if (!options.verbose && !options.debug) {
setLogger({
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
})
}
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
const workspacePath = path.resolve(options.workspace)
if (!apiKey) {
console.error(
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
)
console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`)
process.exit(1)
}
if (!fs.existsSync(workspacePath)) {
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
process.exit(1)
}
if (!isProviderName(options.provider)) {
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
process.exit(1)
}
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
console.error(
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
)
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}`)
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,
})
// 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()
process.exit(1)
}
},
.option("-x, --exit-on-complete", "Exit the process when the task completes (applies to TUI mode only)", false)
.option(
"-w, --wait-on-complete",
"Keep the process running when the task completes (applies to plain text mode only)",
false,
)
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
.option("--no-tui", "Disable TUI, use plain text output")
.action(run)
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
authCommand
.command("login")
.description("Authenticate with Roo Code Cloud")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await login({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
authCommand
.command("logout")
.description("Log out from Roo Code Cloud")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await logout({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
authCommand
.command("status")
.description("Show authentication status")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await status({ verbose: options.verbose })
process.exit(result.authenticated ? 0 : 1)
})
program.parse()

View file

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

View file

@ -0,0 +1,61 @@
export interface DecodedToken {
iss: string
sub: string
exp: number
iat: number
nbf: number
v: number
r?: {
u?: string
o?: string
t: string
}
}
function decodeToken(token: string): DecodedToken | null {
try {
const parts = token.split(".")
if (parts.length !== 3) {
return null
}
const payload = parts[1]
if (!payload) {
return null
}
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
const decoded = Buffer.from(padded, "base64url").toString("utf-8")
return JSON.parse(decoded) as DecodedToken
} catch {
return null
}
}
export function isTokenExpired(token: string, bufferSeconds = 24 * 60 * 60): boolean {
const decoded = decodeToken(token)
if (!decoded?.exp) {
return true
}
const expiresAt = decoded.exp
const bufferTime = Math.floor(Date.now() / 1000) + bufferSeconds
return expiresAt < bufferTime
}
export function isTokenValid(token: string): boolean {
return !isTokenExpired(token, 0)
}
export function getTokenExpirationDate(token: string): Date | null {
const decoded = decodeToken(token)
if (!decoded?.exp) {
return null
}
return new Date(decoded.exp * 1000)
}

View file

@ -0,0 +1,30 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"
import superjson from "superjson"
import type { User, Org } from "./types.js"
export interface ClientConfig {
url: string
authToken: string
}
export interface RooClient {
auth: {
me: {
query: () => Promise<{ type: "user"; user: User } | { type: "org"; org: Org } | null>
}
}
}
export const createClient = ({ url, authToken }: ClientConfig): RooClient => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createTRPCProxyClient<any>({
links: [
httpBatchLink({
url: `${url}/trpc`,
transformer: superjson,
headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}),
}),
],
}) as unknown as RooClient
}

View file

@ -0,0 +1,2 @@
export * from "./types.js"
export * from "./client.js"

View file

@ -0,0 +1,31 @@
export interface User {
id: string
name: string
email: string
imageUrl: string | null
entity: {
id: string
username: string | null
image_url: string
last_name: string
first_name: string
email_addresses: { email_address: string }[]
public_metadata: Record<string, unknown>
}
publicMetadata: Record<string, unknown>
stripeCustomerId: string | null
lastSyncAt: string
deletedAt: string | null
createdAt: string
updatedAt: string
}
export interface Org {
id: string
name: string
slug: string
imageUrl: string | null
createdAt: string
updatedAt: string
deletedAt: string | null
}

View file

@ -0,0 +1,152 @@
import fs from "fs/promises"
import path from "path"
// Use vi.hoisted to make the test directory available to the mock
// This must return the path synchronously since CREDENTIALS_FILE is computed at import time
const { getTestConfigDir } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const os = require("os")
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require("path")
const testRunId = Date.now().toString()
const testConfigDir = path.join(os.tmpdir(), `roo-cli-test-${testRunId}`)
return { getTestConfigDir: () => testConfigDir }
})
vi.mock("../config-dir.js", () => ({
getConfigDir: getTestConfigDir,
}))
// Import after mocking
import { saveToken, loadToken, loadCredentials, clearToken, hasToken, getCredentialsPath } from "../credentials.js"
// Re-derive the test config dir for use in tests (must match the hoisted one)
const actualTestConfigDir = getTestConfigDir()
describe("Token Storage", () => {
const expectedCredentialsFile = path.join(actualTestConfigDir, "cli-credentials.json")
beforeEach(async () => {
// Clear test directory before each test
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
})
afterAll(async () => {
// Clean up test directory
await fs.rm(actualTestConfigDir, { recursive: true, force: true })
})
describe("getCredentialsPath", () => {
it("should return the correct credentials file path", () => {
expect(getCredentialsPath()).toBe(expectedCredentialsFile)
})
})
describe("saveToken", () => {
it("should save token to disk", async () => {
const token = "test-token-123"
await saveToken(token)
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
const credentials = JSON.parse(savedData)
expect(credentials.token).toBe(token)
expect(credentials.createdAt).toBeDefined()
})
it("should save token with user info", async () => {
const token = "test-token-456"
await saveToken(token, { userId: "user_123", orgId: "org_456" })
const savedData = await fs.readFile(expectedCredentialsFile, "utf-8")
const credentials = JSON.parse(savedData)
expect(credentials.token).toBe(token)
expect(credentials.userId).toBe("user_123")
expect(credentials.orgId).toBe("org_456")
})
it("should create config directory if it doesn't exist", async () => {
const token = "test-token-789"
await saveToken(token)
const dirStats = await fs.stat(actualTestConfigDir)
expect(dirStats.isDirectory()).toBe(true)
})
// Unix file permissions don't apply on Windows - skip this test
it.skipIf(process.platform === "win32")("should set restrictive file permissions", async () => {
const token = "test-token-perms"
await saveToken(token)
const stats = await fs.stat(expectedCredentialsFile)
// Check that only owner has read/write (mode 0o600)
const mode = stats.mode & 0o777
expect(mode).toBe(0o600)
})
})
describe("loadToken", () => {
it("should load saved token", async () => {
const token = "test-token-abc"
await saveToken(token)
const loaded = await loadToken()
expect(loaded).toBe(token)
})
it("should return null if no token exists", async () => {
const loaded = await loadToken()
expect(loaded).toBeNull()
})
})
describe("loadCredentials", () => {
it("should load full credentials", async () => {
const token = "test-token-def"
await saveToken(token, { userId: "user_789" })
const credentials = await loadCredentials()
expect(credentials).not.toBeNull()
expect(credentials?.token).toBe(token)
expect(credentials?.userId).toBe("user_789")
expect(credentials?.createdAt).toBeDefined()
})
it("should return null if no credentials exist", async () => {
const credentials = await loadCredentials()
expect(credentials).toBeNull()
})
})
describe("clearToken", () => {
it("should remove saved token", async () => {
const token = "test-token-ghi"
await saveToken(token)
await clearToken()
const loaded = await loadToken()
expect(loaded).toBeNull()
})
it("should not throw if no token exists", async () => {
await expect(clearToken()).resolves.not.toThrow()
})
})
describe("hasToken", () => {
it("should return true if token exists", async () => {
await saveToken("test-token-jkl")
const exists = await hasToken()
expect(exists).toBe(true)
})
it("should return false if no token exists", async () => {
const exists = await hasToken()
expect(exists).toBe(false)
})
})
})

View file

@ -0,0 +1,240 @@
import * as fs from "fs/promises"
import * as path from "path"
import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../history.js"
vi.mock("fs/promises")
vi.mock("os", async (importOriginal) => {
const actual = await importOriginal<typeof import("os")>()
return {
...actual,
default: {
...actual,
homedir: vi.fn(() => "/home/testuser"),
},
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,22 @@
import fs from "fs/promises"
import os from "os"
import path from "path"
const CONFIG_DIR = path.join(os.homedir(), ".roo")
export function getConfigDir(): string {
return CONFIG_DIR
}
export async function ensureConfigDir(): Promise<void> {
try {
await fs.mkdir(CONFIG_DIR, { recursive: true })
} catch (err) {
// Directory may already exist, that's fine.
const error = err as NodeJS.ErrnoException
if (error.code !== "EEXIST") {
throw err
}
}
}

View file

@ -0,0 +1,72 @@
import fs from "fs/promises"
import path from "path"
import { getConfigDir } from "./index.js"
const CREDENTIALS_FILE = path.join(getConfigDir(), "cli-credentials.json")
export interface Credentials {
token: string
createdAt: string
userId?: string
orgId?: string
}
export async function saveToken(token: string, options?: { userId?: string; orgId?: string }): Promise<void> {
await fs.mkdir(getConfigDir(), { recursive: true })
const credentials: Credentials = {
token,
createdAt: new Date().toISOString(),
userId: options?.userId,
orgId: options?.orgId,
}
await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
mode: 0o600, // Read/write for owner only
})
}
export async function loadToken(): Promise<string | null> {
try {
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
const credentials: Credentials = JSON.parse(data)
return credentials.token
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null
}
throw error
}
}
export async function loadCredentials(): Promise<Credentials | null> {
try {
const data = await fs.readFile(CREDENTIALS_FILE, "utf-8")
return JSON.parse(data) as Credentials
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null
}
throw error
}
}
export async function clearToken(): Promise<void> {
try {
await fs.unlink(CREDENTIALS_FILE)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
export async function hasToken(): Promise<boolean> {
const token = await loadToken()
return token !== null
}
export function getCredentialsPath(): string {
return CREDENTIALS_FILE
}

View file

@ -0,0 +1,109 @@
import * as fs from "fs/promises"
import * as path from "path"
import { ensureConfigDir, getConfigDir } from "./config-dir.js"
/** 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(getConfigDir(), "cli-history.json")
}
/**
* 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 ensureConfigDir()
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

@ -0,0 +1,3 @@
export * from "./config-dir.js"
export * from "./settings.js"
export * from "./credentials.js"

View file

@ -0,0 +1,40 @@
import fs from "fs/promises"
import path from "path"
import type { CliSettings } from "../../types/types.js"
import { getConfigDir } from "./index.js"
export function getSettingsPath(): string {
return path.join(getConfigDir(), "cli-settings.json")
}
export async function loadSettings(): Promise<CliSettings> {
try {
const settingsPath = getSettingsPath()
const data = await fs.readFile(settingsPath, "utf-8")
return JSON.parse(data) as CliSettings
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw error
}
}
export async function saveSettings(settings: Partial<CliSettings>): Promise<void> {
const configDir = getConfigDir()
await fs.mkdir(configDir, { recursive: true })
const existing = await loadSettings()
const merged = { ...existing, ...settings }
await fs.writeFile(getSettingsPath(), JSON.stringify(merged, null, 2), {
mode: 0o600,
})
}
export async function resetOnboarding(): Promise<void> {
await saveSettings({ onboardingProviderChoice: undefined })
}

View file

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

View file

@ -0,0 +1,68 @@
import { normalizePath, arePathsEqual } from "../path.js"
// Helper to create platform-specific expected paths
const expectedPath = (...segments: string[]) => {
// On Windows, path.normalize converts forward slashes to backslashes
// and paths like /Users become \Users (without a drive letter)
if (process.platform === "win32") {
return "\\" + segments.join("\\")
}
return "/" + segments.join("/")
}
describe("normalizePath", () => {
it("should remove trailing slashes", () => {
expect(normalizePath("/Users/test/project/")).toBe(expectedPath("Users", "test", "project"))
expect(normalizePath("/Users/test/project//")).toBe(expectedPath("Users", "test", "project"))
})
it("should handle paths without trailing slashes", () => {
expect(normalizePath("/Users/test/project")).toBe(expectedPath("Users", "test", "project"))
})
it("should normalize path separators", () => {
// path.normalize handles this
expect(normalizePath("/Users//test/project")).toBe(expectedPath("Users", "test", "project"))
})
})
describe("arePathsEqual", () => {
it("should return true for identical paths", () => {
expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true)
})
it("should return true for paths differing only by trailing slash", () => {
expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true)
expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true)
})
it("should return false for undefined or empty paths", () => {
expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false)
expect(arePathsEqual("/Users/test/project", undefined)).toBe(false)
expect(arePathsEqual(undefined, undefined)).toBe(false)
expect(arePathsEqual("", "/Users/test/project")).toBe(false)
expect(arePathsEqual("/Users/test/project", "")).toBe(false)
})
it("should return false for different paths", () => {
expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false)
expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false)
})
// Case sensitivity behavior depends on platform
if (process.platform === "darwin" || process.platform === "win32") {
it("should be case-insensitive on macOS/Windows", () => {
expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true)
expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true)
})
} else {
it("should be case-sensitive on Linux", () => {
expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false)
})
}
it("should handle paths with multiple trailing slashes", () => {
expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true)
})
})

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

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

View file

@ -0,0 +1,33 @@
import { createElement } from "react"
import { type OnboardingResult, OnboardingProviderChoice } from "../../types/types.js"
import { login } from "../../commands/index.js"
import { saveSettings } from "../storage/settings.js"
export async function runOnboarding(): Promise<OnboardingResult> {
const { render } = await import("ink")
const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js")
return new Promise<OnboardingResult>((resolve) => {
const onSelect = async (choice: OnboardingProviderChoice) => {
await saveSettings({ onboardingProviderChoice: choice })
app.unmount()
console.log("")
if (choice === OnboardingProviderChoice.Roo) {
const { success: authenticated } = await login()
await saveSettings({ onboardingProviderChoice: choice })
resolve({ choice: OnboardingProviderChoice.Roo, authenticated, skipped: false })
} else {
console.log("Using your own API key.")
console.log("Set your API key via --api-key or environment variable.")
console.log("")
resolve({ choice: OnboardingProviderChoice.Byok, skipped: false })
}
}
const app = render(createElement(OnboardingScreen, { onSelect }))
})
}

View file

@ -0,0 +1,35 @@
import * as path from "path"
/**
* Normalize a path by removing trailing slashes and converting separators.
* This handles cross-platform path comparison issues.
*/
export function normalizePath(p: string): string {
// Remove trailing slashes
let normalized = p.replace(/[/\\]+$/, "")
// Convert to consistent separators using path.normalize
normalized = path.normalize(normalized)
return normalized
}
/**
* Compare two paths for equality, handling:
* - Trailing slashes
* - Path separator differences
* - Case sensitivity (case-insensitive on Windows/macOS)
*/
export function arePathsEqual(path1?: string, path2?: string): boolean {
if (!path1 || !path2) {
return false
}
const normalizedPath1 = normalizePath(path1)
const normalizedPath2 = normalizePath(path2)
// On Windows and macOS, file paths are case-insensitive
if (process.platform === "win32" || process.platform === "darwin") {
return normalizedPath1.toLowerCase() === normalizedPath2.toLowerCase()
}
return normalizedPath1 === normalizedPath2
}

View file

@ -0,0 +1,6 @@
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json")
export const VERSION = packageJson.version

View file

@ -0,0 +1,26 @@
import { reasoningEffortsExtended } from "@roo-code/types"
export const DEFAULT_FLAGS = {
mode: "code",
reasoningEffort: "medium" as const,
model: "anthropic/claude-opus-4.5",
}
export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
/**
* 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
export const ASCII_ROO = ` _,' ___
<__\\__/ \\
\\_ / _\\
\\,\\ / \\\\
// \\\\
,/' \`\\_,`
export const AUTH_BASE_URL = process.env.ROO_AUTH_BASE_URL ?? "https://app.roocode.com"
export const SDK_BASE_URL = process.env.ROO_SDK_BASE_URL ?? "https://cloud-api.roocode.com"

View file

@ -0,0 +1,2 @@
export * from "./types.js"
export * from "./constants.js"

View file

@ -0,0 +1,50 @@
import { ProviderName } from "@roo-code/types"
import { ReasoningEffortExtended } from "@roo-code/types"
export const supportedProviders = [
"anthropic",
"openai-native",
"gemini",
"openrouter",
"vercel-ai-gateway",
"roo",
] as const satisfies ProviderName[]
export type SupportedProvider = (typeof supportedProviders)[number]
export function isSupportedProvider(provider: string): provider is SupportedProvider {
return supportedProviders.includes(provider as SupportedProvider)
}
export type ReasoningEffortFlagOptions = ReasoningEffortExtended | "unspecified" | "disabled"
export type FlagOptions = {
prompt?: string
extension?: string
debug: boolean
yes: boolean
apiKey?: string
provider: SupportedProvider
model?: string
mode?: string
reasoningEffort?: ReasoningEffortFlagOptions
exitOnComplete: boolean
waitOnComplete: boolean
ephemeral: boolean
tui: boolean
}
export enum OnboardingProviderChoice {
Roo = "roo",
Byok = "byok",
}
export interface OnboardingResult {
choice: OnboardingProviderChoice
authenticated?: boolean
skipped: boolean
}
export interface CliSettings {
onboardingProviderChoice?: OnboardingProviderChoice
}

630
apps/cli/src/ui/App.tsx Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 "../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 "../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,69 @@
import { memo } from "react"
import { Text, Box } from "ink"
import type { Toast, ToastType } from "../hooks/useToast.js"
import * as theme from "../theme.js"
interface ToastDisplayProps {
/** The current toast to display (null if no toast) */
toast: Toast | null
}
/**
* Get the color for a toast based on its type
*/
function getToastColor(type: ToastType): string {
switch (type) {
case "success":
return theme.successColor
case "warning":
return theme.warningColor
case "error":
return theme.errorColor
case "info":
default:
return theme.focusColor // cyan for info
}
}
/**
* Get the icon/prefix for a toast based on its type
*/
function getToastIcon(type: ToastType): string {
switch (type) {
case "success":
return "✓"
case "warning":
return "⚠"
case "error":
return "✗"
case "info":
default:
return ""
}
}
/**
* ToastDisplay component for showing ephemeral messages in the status bar.
*
* Displays the current toast with appropriate styling based on type.
* When no toast is present, renders nothing.
*/
function ToastDisplay({ toast }: ToastDisplayProps) {
if (!toast) {
return null
}
const color = getToastColor(toast.type)
const icon = getToastIcon(toast.type)
return (
<Box>
<Text color={color}>
{icon} {toast.message}
</Text>
</Box>
)
}
export default memo(ToastDisplay)

View file

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

View file

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

View file

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

View file

@ -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 single characters (length 1)
expect(getIconChar("folder").length).toBe(1)
expect(getIconChar("file").length).toBe(1)
})
it("should respect useNerdFont override", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
// Force fallback
expect(getIconChar("folder", false)).toBe("▼")
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
// Force Nerd Font
expect(getIconChar("folder", true).length).toBe(1)
})
it("should return empty string for unknown icon", () => {
// @ts-expect-error - testing invalid icon name
expect(getIconChar("unknown")).toBe("")
})
})
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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