diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md new file mode 100644 index 00000000000..c2682a591f0 --- /dev/null +++ b/apps/cli/CHANGELOG.md @@ -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! diff --git a/apps/cli/README.md b/apps/cli/README.md index 3e78192d4e0..d4405364405 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -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 ` | Workspace path to operate in | Current directory | -| `-e, --extension ` | 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 ` | API key for the LLM provider | From env var | -| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | -| `-m, --model ` | Model to use | Provider default | -| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` | - -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. +| Option | Description | Default | +| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- | +| `[workspace]` | Workspace path to operate in (positional argument) | Current directory | +| `-P, --prompt ` | The prompt/task to execute (optional in TUI mode) | None | +| `-e, --extension ` | 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 ` | API key for the LLM provider | From env var | +| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | +| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | +| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-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` | + +## 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: - -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 workflow will: -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 diff --git a/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md b/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md new file mode 100644 index 00000000000..cf05e0755a8 --- /dev/null +++ b/apps/cli/docs/AGENT_LOOP_STATE_DETECTION.md @@ -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(undefined) +const [enableButtons, setEnableButtons] = useState(false) +const [primaryButtonText, setPrimaryButtonText] = useState(undefined) +const [secondaryButtonText, setSecondaryButtonText] = useState(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. diff --git a/apps/cli/install.sh b/apps/cli/install.sh index ca82ecfdd85..1b01e51aa58 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -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 "" } diff --git a/apps/cli/package.json b/apps/cli/package.json index f4c4a3bcb57..ac2770faf61 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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" } } diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh index 43d5298956f..2d6f578100a 100755 --- a/apps/cli/scripts/release.sh +++ b/apps/cli/scripts/release.sh @@ -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 diff --git a/apps/cli/src/__tests__/extension-host.test.ts b/apps/cli/src/__tests__/extension-host.test.ts deleted file mode 100644 index 509ad27d1e6..00000000000 --- a/apps/cli/src/__tests__/extension-host.test.ts +++ /dev/null @@ -1,1164 +0,0 @@ -// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts - -import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js" -import { EventEmitter } from "events" -import type { ProviderName } from "@roo-code/types" - -vi.mock("@roo-code/vscode-shim", () => ({ - createVSCodeAPI: vi.fn(() => ({ - context: { extensionPath: "/test/extension" }, - })), -})) - -/** - * Create a test ExtensionHost with default options - */ -function createTestHost({ - mode = "code", - apiProvider = "openrouter", - model = "test-model", - ...options -}: Partial = {}): ExtensionHost { - return new ExtensionHost({ - mode, - apiProvider, - model, - workspacePath: "/test/workspace", - extensionPath: "/test/extension", - ...options, - }) -} - -// Type for accessing private members -type PrivateHost = Record - -/** - * Helper to access private members for testing - */ -function getPrivate(host: ExtensionHost, key: string): T { - return (host as unknown as PrivateHost)[key] as T -} - -/** - * Helper to call private methods for testing - */ -function callPrivate(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).vscode - delete (global as Record).__extensionHost - }) - - describe("constructor", () => { - it("should store options correctly", () => { - const options: ExtensionHostOptions = { - mode: "code", - workspacePath: "/my/workspace", - extensionPath: "/my/extension", - verbose: true, - quiet: true, - apiKey: "test-key", - apiProvider: "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(host, "pendingMessages")).toEqual([]) - expect(getPrivate(host, "vscode")).toBeNull() - expect(getPrivate(host, "extensionModule")).toBeNull() - }) - }) - - 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" }, - ], - [ - "openai", - "oai-key", - "oai-model", - { apiProvider: "openai", openAiApiKey: "oai-key", openAiModelId: "oai-model" }, - ], - [ - "mistral", - "mis-key", - "mis-model", - { apiProvider: "mistral", mistralApiKey: "mis-key", apiModelId: "mis-model" }, - ], - [ - "deepseek", - "ds-key", - "ds-model", - { apiProvider: "deepseek", deepSeekApiKey: "ds-key", apiModelId: "ds-model" }, - ], - ["xai", "xai-key", "xai-model", { apiProvider: "xai", xaiApiKey: "xai-key", apiModelId: "xai-model" }], - [ - "groq", - "groq-key", - "groq-model", - { apiProvider: "groq", groqApiKey: "groq-key", apiModelId: "groq-model" }, - ], - [ - "fireworks", - "fw-key", - "fw-model", - { apiProvider: "fireworks", fireworksApiKey: "fw-key", apiModelId: "fw-model" }, - ], - [ - "cerebras", - "cer-key", - "cer-model", - { apiProvider: "cerebras", cerebrasApiKey: "cer-key", apiModelId: "cer-model" }, - ], - [ - "sambanova", - "sn-key", - "sn-model", - { apiProvider: "sambanova", sambaNovaApiKey: "sn-key", apiModelId: "sn-model" }, - ], - [ - "ollama", - "oll-key", - "oll-model", - { apiProvider: "ollama", ollamaApiKey: "oll-key", ollamaModelId: "oll-model" }, - ], - ["lmstudio", undefined, "lm-model", { apiProvider: "lmstudio", lmStudioModelId: "lm-model" }], - [ - "litellm", - "lite-key", - "lite-model", - { apiProvider: "litellm", litellmApiKey: "lite-key", litellmModelId: "lite-model" }, - ], - [ - "huggingface", - "hf-key", - "hf-model", - { apiProvider: "huggingface", huggingFaceApiKey: "hf-key", huggingFaceModelId: "hf-model" }, - ], - ["chutes", "ch-key", "ch-model", { apiProvider: "chutes", chutesApiKey: "ch-key", apiModelId: "ch-model" }], - [ - "featherless", - "fl-key", - "fl-model", - { apiProvider: "featherless", featherlessApiKey: "fl-key", apiModelId: "fl-model" }, - ], - [ - "unbound", - "ub-key", - "ub-model", - { apiProvider: "unbound", unboundApiKey: "ub-key", unboundModelId: "ub-model" }, - ], - [ - "requesty", - "req-key", - "req-model", - { apiProvider: "requesty", requestyApiKey: "req-key", requestyModelId: "req-model" }, - ], - [ - "deepinfra", - "di-key", - "di-model", - { apiProvider: "deepinfra", deepInfraApiKey: "di-key", deepInfraModelId: "di-model" }, - ], - [ - "vercel-ai-gateway", - "vai-key", - "vai-model", - { - apiProvider: "vercel-ai-gateway", - vercelAiGatewayApiKey: "vai-key", - vercelAiGatewayModelId: "vai-model", - }, - ], - ["zai", "zai-key", "zai-model", { apiProvider: "zai", zaiApiKey: "zai-key", apiModelId: "zai-model" }], - [ - "baseten", - "bt-key", - "bt-model", - { apiProvider: "baseten", basetenApiKey: "bt-key", apiModelId: "bt-model" }, - ], - ["doubao", "db-key", "db-model", { apiProvider: "doubao", doubaoApiKey: "db-key", apiModelId: "db-model" }], - [ - "moonshot", - "ms-key", - "ms-model", - { apiProvider: "moonshot", moonshotApiKey: "ms-key", apiModelId: "ms-model" }, - ], - [ - "minimax", - "mm-key", - "mm-model", - { apiProvider: "minimax", minimaxApiKey: "mm-key", apiModelId: "mm-model" }, - ], - [ - "io-intelligence", - "io-key", - "io-model", - { apiProvider: "io-intelligence", ioIntelligenceApiKey: "io-key", ioIntelligenceModelId: "io-model" }, - ], - ])("should configure %s provider correctly", (provider, apiKey, model, expected) => { - const host = createTestHost({ - apiProvider: provider as ProviderName, - apiKey, - model, - }) - - const config = callPrivate>(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>(host, "buildApiConfiguration") - - expect(config.apiProvider).toBe("openrouter") - }) - - it("should handle missing apiKey gracefully", () => { - const host = createTestHost({ - apiProvider: "anthropic", - model: "test-model", - }) - - const config = callPrivate>(host, "buildApiConfiguration") - - expect(config.apiProvider).toBe("anthropic") - expect(config.apiKey).toBeUndefined() - expect(config.apiModelId).toBe("test-model") - }) - - it("should use default config for unknown providers", () => { - const host = createTestHost({ - apiProvider: "unknown-provider" as ProviderName, - apiKey: "test-key", - model: "test-model", - }) - - const config = callPrivate>(host, "buildApiConfiguration") - - expect(config.apiProvider).toBe("unknown-provider") - expect(config.apiKey).toBe("test-key") - 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>(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>(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: "test1" }) - host.sendToExtension({ type: "test2" }) - - // Mark ready (should flush) - host.markWebviewReady() - - // Check that webviewMessage events were emitted for pending messages - expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" }) - expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" }) - }) - }) - }) - - describe("sendToExtension", () => { - it("should queue message when webview not ready", () => { - const host = createTestHost() - const message = { type: "test" } - - host.sendToExtension(message) - - const pending = getPrivate(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 = { type: "test" } - - 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: "test" }) - - const pending = getPrivate(host, "pendingMessages") - expect(pending).toHaveLength(0) - }) - }) - - describe("handleExtensionMessage", () => { - it("should route state messages to handleStateMessage", () => { - const host = createTestHost() - const handleStateSpy = spyOnPrivate(host, "handleStateMessage") - - callPrivate(host, "handleExtensionMessage", { type: "state", state: {} }) - - expect(handleStateSpy).toHaveBeenCalled() - }) - - it("should route messageUpdated to handleMessageUpdated", () => { - const host = createTestHost() - const handleMsgUpdatedSpy = spyOnPrivate(host, "handleMessageUpdated") - - callPrivate(host, "handleExtensionMessage", { type: "messageUpdated", clineMessage: {} }) - - expect(handleMsgUpdatedSpy).toHaveBeenCalled() - }) - - it("should route action messages to handleActionMessage", () => { - const host = createTestHost() - const handleActionSpy = spyOnPrivate(host, "handleActionMessage") - - callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" }) - - expect(handleActionSpy).toHaveBeenCalled() - }) - - it("should route invoke messages to handleInvokeMessage", () => { - const host = createTestHost() - const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage") - - callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" }) - - expect(handleInvokeSpy).toHaveBeenCalled() - }) - }) - - describe("handleSayMessage", () => { - let host: ExtensionHost - let outputSpy: ReturnType - let outputErrorSpy: ReturnType - - beforeEach(() => { - host = createTestHost() - // Mock process.stdout.write and process.stderr.write which are used by output() and outputError() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - vi.spyOn(process.stderr, "write").mockImplementation(() => true) - // Spy on the output methods - outputSpy = spyOnPrivate(host, "output") - outputErrorSpy = spyOnPrivate(host, "outputError") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should emit taskComplete for completion_result", () => { - const emitSpy = vi.spyOn(host, "emit") - - callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) - - expect(emitSpy).toHaveBeenCalledWith("taskComplete") - expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task done") - }) - - it("should output error messages without emitting taskError", () => { - const emitSpy = vi.spyOn(host, "emit") - - callPrivate(host, "handleSayMessage", 123, "error", "Something went wrong", false) - - // Errors are informational - they don't terminate the task - // The agent should decide what to do next - expect(emitSpy).not.toHaveBeenCalledWith("taskError", "Something went wrong") - expect(outputErrorSpy).toHaveBeenCalledWith("\n[error]", "Something went wrong") - }) - - it("should handle command_output messages", () => { - // Mock writeStream since command_output now uses it directly - const writeStreamSpy = spyOnPrivate(host, "writeStream") - - callPrivate(host, "handleSayMessage", 123, "command_output", "output text", false) - - // command_output now uses writeStream to bypass quiet mode - expect(writeStreamSpy).toHaveBeenCalledWith("\n[command output] ") - expect(writeStreamSpy).toHaveBeenCalledWith("output text") - expect(writeStreamSpy).toHaveBeenCalledWith("\n") - }) - - it("should handle tool messages", () => { - callPrivate(host, "handleSayMessage", 123, "tool", "tool usage", false) - - expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "tool usage") - }) - - it("should skip already displayed complete messages", () => { - // First display - callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) - outputSpy.mockClear() - - // Second display should be skipped - callPrivate(host, "handleSayMessage", 123, "completion_result", "Task done", false) - - expect(outputSpy).not.toHaveBeenCalled() - }) - - it("should not output completion_result for partial messages", () => { - const emitSpy = vi.spyOn(host, "emit") - - // Partial message should not trigger output or taskComplete - callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) - - expect(outputSpy).not.toHaveBeenCalled() - expect(emitSpy).not.toHaveBeenCalledWith("taskComplete") - }) - - it("should output completion_result text when complete message arrives after partial", () => { - const emitSpy = vi.spyOn(host, "emit") - - // First, a partial message with empty text (simulates streaming) - callPrivate(host, "handleSayMessage", 123, "completion_result", "", true) - outputSpy.mockClear() - emitSpy.mockClear() - - // Then, the complete message with the actual completion text - callPrivate(host, "handleSayMessage", 123, "completion_result", "Task completed successfully!", false) - - expect(outputSpy).toHaveBeenCalledWith("\n[task complete]", "Task completed successfully!") - expect(emitSpy).toHaveBeenCalledWith("taskComplete") - }) - - it("should track displayed messages", () => { - callPrivate(host, "handleSayMessage", 123, "tool", "test", false) - - const displayed = getPrivate>(host, "displayedMessages") - expect(displayed.has(123)).toBe(true) - }) - }) - - describe("handleAskMessage", () => { - let host: ExtensionHost - let outputSpy: ReturnType - - beforeEach(() => { - // Use nonInteractive mode for display-only behavior tests - host = createTestHost({ nonInteractive: true }) - // Mock process.stdout.write which is used by output() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - outputSpy = spyOnPrivate(host, "output") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should handle command type in non-interactive mode", () => { - callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) - - expect(outputSpy).toHaveBeenCalledWith("\n[command]", "ls -la") - }) - - it("should handle tool type with JSON parsing in non-interactive mode", () => { - const toolInfo = JSON.stringify({ tool: "write_file", path: "/test/file.txt" }) - - callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) - - expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") - expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt") - }) - - it("should handle tool type with content preview in non-interactive mode", () => { - const toolInfo = JSON.stringify({ - tool: "write_file", - content: "This is the content that will be written to the file. It might be long.", - }) - - callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) - - // Content is now shown (all tool parameters are displayed) - expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file") - expect(outputSpy).toHaveBeenCalledWith( - " content: This is the content that will be written to the file. It might be long.", - ) - }) - - it("should handle tool type with invalid JSON in non-interactive mode", () => { - callPrivate(host, "handleAskMessage", 123, "tool", "not json", false) - - expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "not json") - }) - - it("should not display duplicate messages for same ts", () => { - const toolInfo = JSON.stringify({ tool: "read_file" }) - - // First call - callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) - outputSpy.mockClear() - - // Same ts - should be duplicate (already displayed) - callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false) - - // Should not log again - expect(outputSpy).not.toHaveBeenCalled() - }) - - it("should handle other ask types in non-interactive mode", () => { - callPrivate(host, "handleAskMessage", 123, "question", "What is your name?", false) - - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") - }) - - it("should skip partial messages", () => { - callPrivate(host, "handleAskMessage", 123, "command", "ls -la", true) - - // Partial messages should be skipped - expect(outputSpy).not.toHaveBeenCalled() - }) - }) - - describe("handleAskMessage - interactive mode", () => { - let host: ExtensionHost - let outputSpy: ReturnType - - beforeEach(() => { - // Default interactive mode - host = createTestHost({ nonInteractive: false }) - // Mock process.stdout.write which is used by output() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - outputSpy = spyOnPrivate(host, "output") - // Mock readline to prevent actual prompting - vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should mark ask as pending in interactive mode", () => { - // This will try to prompt, but we're testing the pendingAsks tracking - callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) - - const pendingAsks = getPrivate>(host, "pendingAsks") - expect(pendingAsks.has(123)).toBe(true) - }) - - it("should skip already pending asks", () => { - // First call - marks as pending - callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) - const callCount1 = outputSpy.mock.calls.length - - // Second call - should skip - callPrivate(host, "handleAskMessage", 123, "command", "ls -la", false) - const callCount2 = outputSpy.mock.calls.length - - // Should not have logged again - expect(callCount2).toBe(callCount1) - }) - }) - - describe("handleFollowupQuestion", () => { - let host: ExtensionHost - let outputSpy: ReturnType - - beforeEach(() => { - host = createTestHost({ nonInteractive: false }) - // Mock process.stdout.write which is used by output() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - outputSpy = spyOnPrivate(host, "output") - // Mock readline to prevent actual prompting - vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should parse followup question JSON with suggestion objects containing answer and mode", async () => { - // This is the format from AskFollowupQuestionTool - // { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } - const text = JSON.stringify({ - question: "What would you like to do?", - suggest: [ - { answer: "Write code", mode: "code" }, - { answer: "Debug issue", mode: "debug" }, - { answer: "Just explain", mode: null }, - ], - }) - - // Call the handler (it will try to prompt but we just want to test parsing) - callPrivate(host, "handleFollowupQuestion", 123, text) - - // Should display the question - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") - - // Should display suggestions with answer text and mode hints - expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") - expect(outputSpy).toHaveBeenCalledWith(" 1. Write code (mode: code)") - expect(outputSpy).toHaveBeenCalledWith(" 2. Debug issue (mode: debug)") - expect(outputSpy).toHaveBeenCalledWith(" 3. Just explain") - }) - - it("should handle followup question with suggestions that have no mode", async () => { - const text = JSON.stringify({ - question: "What path?", - suggest: [{ answer: "./src/file.ts" }, { answer: "./lib/other.ts" }], - }) - - callPrivate(host, "handleFollowupQuestion", 123, text) - - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What path?") - expect(outputSpy).toHaveBeenCalledWith(" 1. ./src/file.ts") - expect(outputSpy).toHaveBeenCalledWith(" 2. ./lib/other.ts") - }) - - it("should handle plain text (non-JSON) as the question", async () => { - callPrivate(host, "handleFollowupQuestion", 123, "What is your name?") - - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What is your name?") - }) - - it("should handle empty suggestions array", async () => { - const text = JSON.stringify({ - question: "Tell me more", - suggest: [], - }) - - callPrivate(host, "handleFollowupQuestion", 123, text) - - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Tell me more") - // Should not show "Suggested answers:" if array is empty - expect(outputSpy).not.toHaveBeenCalledWith("\nSuggested answers:") - }) - }) - - describe("handleFollowupQuestionWithTimeout", () => { - let host: ExtensionHost - let outputSpy: ReturnType - const originalIsTTY = process.stdin.isTTY - - beforeEach(() => { - // Non-interactive mode uses the timeout variant - host = createTestHost({ nonInteractive: true }) - // Mock process.stdout.write which is used by output() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - outputSpy = spyOnPrivate(host, "output") - // Mock stdin - set isTTY to false so setRawMode is not called - Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) - vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) - }) - - afterEach(() => { - vi.restoreAllMocks() - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) - }) - - it("should parse followup question JSON and display question with suggestions", () => { - const text = JSON.stringify({ - question: "What would you like to do?", - suggest: [ - { answer: "Option A", mode: "code" }, - { answer: "Option B", mode: null }, - ], - }) - - // Call the handler - it will display the question and start the timeout - callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) - - // Should display the question - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "What would you like to do?") - - // Should display suggestions - expect(outputSpy).toHaveBeenCalledWith("\nSuggested answers:") - expect(outputSpy).toHaveBeenCalledWith(" 1. Option A (mode: code)") - expect(outputSpy).toHaveBeenCalledWith(" 2. Option B") - }) - - it("should handle non-JSON text as plain question", () => { - callPrivate(host, "handleFollowupQuestionWithTimeout", 123, "Plain question text") - - expect(outputSpy).toHaveBeenCalledWith("\n[question]", "Plain question text") - }) - - it("should include auto-select hint in prompt when suggestions exist", () => { - const stdoutWriteSpy = vi.spyOn(process.stdout, "write") - const text = JSON.stringify({ - question: "Choose one", - suggest: [{ answer: "First option" }], - }) - - callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text) - - // Should show prompt with timeout hint - expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s")) - }) - }) - - describe("handleAskMessageNonInteractive - followup handling", () => { - let host: ExtensionHost - let _outputSpy: ReturnType - let handleFollowupTimeoutSpy: ReturnType - const originalIsTTY = process.stdin.isTTY - - beforeEach(() => { - host = createTestHost({ nonInteractive: true }) - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - _outputSpy = spyOnPrivate(host, "output") - handleFollowupTimeoutSpy = spyOnPrivate(host, "handleFollowupQuestionWithTimeout") - // Mock stdin - set isTTY to false so setRawMode is not called - Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true }) - vi.spyOn(process.stdin, "on").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin) - vi.spyOn(process.stdin, "removeListener").mockImplementation(() => process.stdin) - }) - - afterEach(() => { - vi.restoreAllMocks() - Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, writable: true }) - }) - - it("should call handleFollowupQuestionWithTimeout for followup asks in non-interactive mode", () => { - const text = JSON.stringify({ - question: "What to do?", - suggest: [{ answer: "Do something" }], - }) - - callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) - - expect(handleFollowupTimeoutSpy).toHaveBeenCalledWith(123, text) - }) - - it("should add ts to pendingAsks for followup in non-interactive mode", () => { - const text = JSON.stringify({ - question: "What to do?", - suggest: [{ answer: "Do something" }], - }) - - callPrivate(host, "handleAskMessageNonInteractive", 123, "followup", text) - - const pendingAsks = getPrivate>(host, "pendingAsks") - expect(pendingAsks.has(123)).toBe(true) - }) - }) - - describe("streamContent", () => { - let host: ExtensionHost - let writeStreamSpy: ReturnType - - beforeEach(() => { - host = createTestHost() - // Mock process.stdout.write - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - writeStreamSpy = spyOnPrivate(host, "writeStream") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should output header and text for new messages", () => { - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - - expect(writeStreamSpy).toHaveBeenCalledWith("\n[Test] ") - expect(writeStreamSpy).toHaveBeenCalledWith("Hello") - }) - - it("should compute delta for growing text", () => { - // First call - establishes baseline - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - writeStreamSpy.mockClear() - - // Second call - should only output delta - callPrivate(host, "streamContent", 123, "Hello World", "[Test]") - - expect(writeStreamSpy).toHaveBeenCalledWith(" World") - }) - - it("should skip when text has not grown", () => { - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - writeStreamSpy.mockClear() - - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - - expect(writeStreamSpy).not.toHaveBeenCalled() - }) - - it("should skip when text does not match prefix", () => { - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - writeStreamSpy.mockClear() - - // Different text entirely - callPrivate(host, "streamContent", 123, "Goodbye", "[Test]") - - expect(writeStreamSpy).not.toHaveBeenCalled() - }) - - it("should track currently streaming ts", () => { - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - - expect(getPrivate(host, "currentlyStreamingTs")).toBe(123) - }) - }) - - describe("finishStream", () => { - let host: ExtensionHost - let writeStreamSpy: ReturnType - - beforeEach(() => { - host = createTestHost() - vi.spyOn(process.stdout, "write").mockImplementation(() => true) - writeStreamSpy = spyOnPrivate(host, "writeStream") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should add newline when finishing current stream", () => { - // Set up streaming state - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - writeStreamSpy.mockClear() - - callPrivate(host, "finishStream", 123) - - expect(writeStreamSpy).toHaveBeenCalledWith("\n") - expect(getPrivate(host, "currentlyStreamingTs")).toBeNull() - }) - - it("should not add newline for different ts", () => { - callPrivate(host, "streamContent", 123, "Hello", "[Test]") - writeStreamSpy.mockClear() - - callPrivate(host, "finishStream", 456) - - expect(writeStreamSpy).not.toHaveBeenCalled() - }) - }) - - describe("quiet mode", () => { - describe("setupQuietMode", () => { - it("should not modify console when quiet mode disabled", () => { - const host = createTestHost({ quiet: false }) - const originalLog = console.log - - callPrivate(host, "setupQuietMode") - - expect(console.log).toBe(originalLog) - }) - - it("should suppress console.log, warn, debug, info when enabled", () => { - const host = createTestHost({ quiet: true }) - 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({ quiet: true }) - const originalError = console.error - - callPrivate(host, "setupQuietMode") - - expect(console.error).toBe(originalError) - - callPrivate(host, "restoreConsole") - }) - - it("should store original console methods", () => { - const host = createTestHost({ quiet: true }) - 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({ quiet: true }) - 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({ quiet: false }) - - 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).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).extensionModule = { - deactivate: deactivateMock, - } - - await host.dispose() - - expect(deactivateMock).toHaveBeenCalled() - }) - - it("should clear vscode reference", async () => { - ;(host as unknown as Record).vscode = { context: {} } - - await host.dispose() - - expect(getPrivate(host, "vscode")).toBeNull() - }) - - it("should clear extensionModule reference", async () => { - ;(host as unknown as Record).extensionModule = {} - - await host.dispose() - - expect(getPrivate(host, "extensionModule")).toBeNull() - }) - - it("should clear webviewProviders", async () => { - host.registerWebviewProvider("test", {}) - - await host.dispose() - - const providers = getPrivate>(host, "webviewProviders") - expect(providers.size).toBe(0) - }) - - it("should delete global vscode", async () => { - ;(global as Record).vscode = {} - - await host.dispose() - - expect((global as Record).vscode).toBeUndefined() - }) - - it("should delete global __extensionHost", async () => { - ;(global as Record).__extensionHost = {} - - await host.dispose() - - expect((global as Record).__extensionHost).toBeUndefined() - }) - - it("should restore console if it was suppressed", async () => { - const restoreConsoleSpy = spyOnPrivate(host, "restoreConsole") - - await host.dispose() - - expect(restoreConsoleSpy).toHaveBeenCalled() - }) - }) - - describe("waitForCompletion", () => { - it("should resolve when taskComplete is emitted", async () => { - const host = createTestHost() - - const promise = callPrivate>(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>(host, "waitForCompletion") - - setTimeout(() => host.emit("taskError", "Test error"), 10) - - await expect(promise).rejects.toThrow("Test error") - }) - - it("should timeout after configured duration", async () => { - const host = createTestHost() - - // Use fake timers for this test - vi.useFakeTimers() - - const promise = callPrivate>(host, "waitForCompletion") - - // Fast-forward past the timeout (10 minutes) - vi.advanceTimersByTime(10 * 60 * 1000 + 1) - - await expect(promise).rejects.toThrow("Task timed out") - - vi.useRealTimers() - }) - }) -}) diff --git a/apps/cli/src/__tests__/integration.test.ts b/apps/cli/src/__tests__/index.test.ts similarity index 66% rename from apps/cli/src/__tests__/integration.test.ts rename to apps/cli/src/__tests__/index.test.ts index 158438decbb..cc146259e92 100644 --- a/apps/cli/src/__tests__/integration.test.ts +++ b/apps/cli/src/__tests__/index.test.ts @@ -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 + }) }) diff --git a/apps/cli/src/commands/auth/index.ts b/apps/cli/src/commands/auth/index.ts new file mode 100644 index 00000000000..52ae7673a7e --- /dev/null +++ b/apps/cli/src/commands/auth/index.ts @@ -0,0 +1,3 @@ +export * from "./login.js" +export * from "./logout.js" +export * from "./status.js" diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts new file mode 100644 index 00000000000..15e0479cb7b --- /dev/null +++ b/apps/cli/src/commands/auth/login.ts @@ -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 { + 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 { + 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 { + 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() + } + }) + }) +} diff --git a/apps/cli/src/commands/auth/logout.ts b/apps/cli/src/commands/auth/logout.ts new file mode 100644 index 00000000000..4ddd80025b1 --- /dev/null +++ b/apps/cli/src/commands/auth/logout.ts @@ -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 { + 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 } +} diff --git a/apps/cli/src/commands/auth/status.ts b/apps/cli/src/commands/auth/status.ts new file mode 100644 index 00000000000..e45a636414b --- /dev/null +++ b/apps/cli/src/commands/auth/status.ts @@ -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 { + 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"}` +} diff --git a/apps/cli/src/commands/cli/index.ts b/apps/cli/src/commands/cli/index.ts new file mode 100644 index 00000000000..89e8e9f1ba6 --- /dev/null +++ b/apps/cli/src/commands/cli/index.ts @@ -0,0 +1 @@ +export * from "./run.js" diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts new file mode 100644 index 00000000000..a091271a71c --- /dev/null +++ b/apps/cli/src/commands/cli/run.ts @@ -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 [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) + } + } +} diff --git a/apps/cli/src/commands/index.ts b/apps/cli/src/commands/index.ts new file mode 100644 index 00000000000..717a7040ef6 --- /dev/null +++ b/apps/cli/src/commands/index.ts @@ -0,0 +1,2 @@ +export * from "./auth/index.js" +export * from "./cli/index.js" diff --git a/apps/cli/src/extension-client/agent-state.ts b/apps/cli/src/extension-client/agent-state.ts new file mode 100644 index 00000000000..35235c3df00 --- /dev/null +++ b/apps/cli/src/extension-client/agent-state.ts @@ -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 +} diff --git a/apps/cli/src/extension-client/client.test.ts b/apps/cli/src/extension-client/client.test.ts new file mode 100644 index 00000000000..2bbbd6fbcc3 --- /dev/null +++ b/apps/cli/src/extension-client/client.test.ts @@ -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 { + 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) + }) + }) +}) diff --git a/apps/cli/src/extension-client/client.ts b/apps/cli/src/extension-client/client.ts new file mode 100644 index 00000000000..a1c5397cb22 --- /dev/null +++ b/apps/cli/src/extension-client/client.ts @@ -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(event: K, listener: (payload: ClientEventMap[K]) => void): () => void { + return this.emitter.on(event, listener) + } + + /** + * Subscribe to an event, triggered only once. + */ + once(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.once(event, listener) + } + + /** + * Unsubscribe from an event. + */ + off(event: K, listener: (payload: ClientEventMap[K]) => void): void { + this.emitter.off(event, listener) + } + + /** + * Remove all listeners for an event, or all events. + */ + removeAllListeners(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 + }, + } +} diff --git a/apps/cli/src/extension-client/events.ts b/apps/cli/src/extension-client/events.ts new file mode 100644 index 00000000000..b9eaf929fab --- /dev/null +++ b/apps/cli/src/extension-client/events.ts @@ -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(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(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(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(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(event?: K): void { + if (event) { + this.emitter.removeAllListeners(event) + } else { + this.emitter.removeAllListeners() + } + } + + /** + * Get the number of listeners for an event. + */ + listenerCount(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 = (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() + * + * const unsubscribe = stateObservable.subscribe((state) => { + * console.log('New state:', state) + * }) + * + * // Later... + * unsubscribe() + * ``` + */ +export class Observable { + private observers: Set> = 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): 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() + } +} diff --git a/apps/cli/src/extension-client/index.ts b/apps/cli/src/extension-client/index.ts new file mode 100644 index 00000000000..82d98f19902 --- /dev/null +++ b/apps/cli/src/extension-client/index.ts @@ -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" diff --git a/apps/cli/src/extension-client/message-processor.ts b/apps/cli/src/extension-client/message-processor.ts new file mode 100644 index 00000000000..e44987229d9 --- /dev/null +++ b/apps/cli/src/extension-client/message-processor.ts @@ -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 + + 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 + + // 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 + + // 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 + } +} diff --git a/apps/cli/src/extension-client/state-store.ts b/apps/cli/src/extension-client/state-store.ts new file mode 100644 index 00000000000..9c4fc78f01d --- /dev/null +++ b/apps/cli/src/extension-client/state-store.ts @@ -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 +} + +/** + * 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 + private agentStateObservable: Observable + + /** + * 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(this.state) + this.agentStateObservable = new Observable(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): 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 +} diff --git a/apps/cli/src/extension-client/types.ts b/apps/cli/src/extension-client/types.ts new file mode 100644 index 00000000000..fee429fc0d1 --- /dev/null +++ b/apps/cli/src/extension-client/types.ts @@ -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 +} diff --git a/apps/cli/src/extension-host.ts b/apps/cli/src/extension-host.ts deleted file mode 100644 index 33963869244..00000000000 --- a/apps/cli/src/extension-host.ts +++ /dev/null @@ -1,1663 +0,0 @@ -/** - * ExtensionHost - Loads and runs the Roo Code extension in CLI mode - * - * This class is responsible for: - * 1. Creating the vscode-shim mock - * 2. Loading the extension bundle via require() - * 3. Activating the extension - * 4. Managing bidirectional message flow between CLI and extension - */ - -import { EventEmitter } from "events" -import { createRequire } from "module" -import path from "path" -import { fileURLToPath } from "url" -import fs from "fs" -import readline from "readline" - -import { createVSCodeAPI, setRuntimeConfigValues } from "@roo-code/vscode-shim" -import { ProviderName, ReasoningEffortExtended, RooCodeSettings } from "@roo-code/types" - -// Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) -// When bundled, import.meta.url points to dist/index.js, so go up to package root -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CLI_PACKAGE_ROOT = path.resolve(__dirname, "..") - -export interface ExtensionHostOptions { - mode: string - reasoningEffort?: ReasoningEffortExtended | "disabled" - apiProvider: ProviderName - apiKey?: string - model: string - workspacePath: string - extensionPath: string - verbose?: boolean - quiet?: boolean - nonInteractive?: boolean -} - -interface ExtensionModule { - activate: (context: unknown) => Promise - deactivate?: () => Promise -} - -/** - * Local interface for webview provider (matches VSCode API) - */ -interface WebviewViewProvider { - resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise -} - -export class ExtensionHost extends EventEmitter { - private vscode: ReturnType | null = null - private extensionModule: ExtensionModule | null = null - private extensionAPI: unknown = null - private webviewProviders: Map = new Map() - private options: ExtensionHostOptions - private isWebviewReady = false - private pendingMessages: unknown[] = [] - private messageListener: ((message: unknown) => void) | null = null - - 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 - - // Track pending asks that need a response (by ts) - private pendingAsks: Set = new Set() - - // Readline interface for interactive prompts - private rl: readline.Interface | null = null - - // Track displayed messages by ts to avoid duplicates and show updates - private displayedMessages: Map = new Map() - - // Track streamed content by ts for delta computation - private streamedContent: Map = new Map() - - // Track message processing for verbose debug output - private processedMessageCount = 0 - - // Track if we're currently streaming a message (to manage newlines) - private currentlyStreamingTs: number | null = null - - constructor(options: ExtensionHostOptions) { - super() - this.options = options - } - - private log(...args: unknown[]): void { - if (this.options.verbose) { - // Use original console if available to avoid quiet mode suppression - const logFn = this.originalConsole?.log || console.log - logFn("[ExtensionHost]", ...args) - } - } - - /** - * Suppress Node.js warnings (like MaxListenersExceededWarning) - * This is called regardless of quiet mode to prevent warnings from interrupting output - */ - private suppressNodeWarnings(): void { - // Suppress process warnings (like MaxListenersExceededWarning) - this.originalProcessEmitWarning = process.emitWarning - process.emitWarning = () => {} - - // Also suppress via the warning event handler - process.on("warning", () => {}) - } - - /** - * Suppress console output from the extension when quiet mode is enabled. - * This intercepts console.log, console.warn, console.info, console.debug - * but allows console.error through for critical errors. - */ - private setupQuietMode(): void { - if (!this.options.quiet) { - return - } - - // Save original console methods - this.originalConsole = { - log: console.log, - warn: console.warn, - error: console.error, - debug: console.debug, - info: console.info, - } - - // Replace with no-op functions (except error) - console.log = () => {} - console.warn = () => {} - console.debug = () => {} - console.info = () => {} - // Keep console.error for critical errors - } - - /** - * Restore original console methods and process.emitWarning - */ - 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 - } - } - - async activate(): Promise { - this.log("Activating extension...") - - // Suppress Node.js warnings (like MaxListenersExceededWarning) before anything else - this.suppressNodeWarnings() - - // Set up quiet mode before loading extension - this.setupQuietMode() - - // Verify extension path exists - const bundlePath = path.join(this.options.extensionPath, "extension.js") - if (!fs.existsSync(bundlePath)) { - this.restoreConsole() - throw new Error(`Extension bundle not found at: ${bundlePath}`) - } - - // 1. Create VSCode API mock - this.log("Creating VSCode API mock...") - this.log("Using appRoot:", CLI_PACKAGE_ROOT) - this.vscode = createVSCodeAPI( - this.options.extensionPath, - this.options.workspacePath, - undefined, // identity - { appRoot: CLI_PACKAGE_ROOT }, // options - point appRoot to CLI package for ripgrep - ) - - // 2. Set global vscode reference for the extension - ;(global as Record).vscode = this.vscode - - // 3. Set up __extensionHost global for webview registration - // This is used by WindowAPI.registerWebviewViewProvider - ;(global as Record).__extensionHost = this - - // 4. Set up module resolution to intercept require('vscode') - 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) - } - - // Add the mock to require.cache - // Use 'as unknown as' to satisfy TypeScript's Module type requirements - 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 - - this.log("Loading extension bundle from:", bundlePath) - - // 5. Load extension bundle - try { - this.extensionModule = require(bundlePath) as ExtensionModule - } catch (error) { - // Restore module resolution before throwing - Module._resolveFilename = originalResolve - throw new Error( - `Failed to load extension bundle: ${error instanceof Error ? error.message : String(error)}`, - ) - } - - // 6. Restore module resolution - Module._resolveFilename = originalResolve - - this.log("Activating extension...") - - // 7. Activate extension - try { - this.extensionAPI = await this.extensionModule.activate(this.vscode.context) - this.log("Extension activated successfully") - } catch (error) { - throw new Error(`Failed to activate extension: ${error instanceof Error ? error.message : String(error)}`) - } - } - - /** - * Called by WindowAPI.registerWebviewViewProvider - * This is triggered when the extension registers its sidebar webview provider - */ - registerWebviewProvider(viewId: string, provider: WebviewViewProvider): void { - this.log(`Webview provider registered: ${viewId}`) - this.webviewProviders.set(viewId, provider) - - // The WindowAPI will call resolveWebviewView automatically - // We don't need to do anything here - } - - /** - * Called when a webview provider is disposed - */ - unregisterWebviewProvider(viewId: string): void { - this.log(`Webview provider unregistered: ${viewId}`) - this.webviewProviders.delete(viewId) - } - - /** - * Returns true during initial extension setup - * Used to prevent the extension from aborting tasks during initialization - */ - isInInitialSetup(): boolean { - return !this.isWebviewReady - } - - /** - * Called by WindowAPI after resolveWebviewView completes - * This indicates the webview is ready to receive messages - */ - markWebviewReady(): void { - this.log("Webview marked as ready") - this.isWebviewReady = true - this.emit("webviewReady") - - // Flush any pending messages - this.flushPendingMessages() - } - - /** - * Send any messages that were queued before the webview was ready - */ - private flushPendingMessages(): void { - if (this.pendingMessages.length > 0) { - this.log(`Flushing ${this.pendingMessages.length} pending messages`) - for (const message of this.pendingMessages) { - this.emit("webviewMessage", message) - } - this.pendingMessages = [] - } - } - - /** - * Send a message to the extension (simulating webview -> extension communication). - */ - sendToExtension(message: unknown): void { - if (!this.isWebviewReady) { - this.log("Queueing message (webview not ready):", message) - this.pendingMessages.push(message) - return - } - - this.log("Sending message to extension:", message) - this.emit("webviewMessage", message) - } - - private applyRuntimeSettings(settings: RooCodeSettings): void { - if (this.options.mode) { - settings.mode = this.options.mode - } - - if (this.options.reasoningEffort) { - if (this.options.reasoningEffort === "disabled") { - settings.enableReasoningEffort = false - } else { - settings.enableReasoningEffort = true - settings.reasoningEffort = this.options.reasoningEffort - } - } - - // Update vscode-shim runtime configuration so - // vscode.workspace.getConfiguration() returns correct values. - setRuntimeConfigValues("roo-cline", settings as Record) - } - - /** - * Build the provider-specific API configuration - * Each provider uses different field names for API key and model - */ - private buildApiConfiguration(): RooCodeSettings { - const provider = this.options.apiProvider || "anthropic" - const apiKey = this.options.apiKey - const model = this.options.model - - // Base config with provider. - const config: RooCodeSettings = { apiProvider: provider } - - // Map provider to the correct API key and model field names. - switch (provider) { - case "anthropic": - if (apiKey) config.apiKey = apiKey - if (model) config.apiModelId = model - break - - case "openrouter": - if (apiKey) config.openRouterApiKey = apiKey - if (model) config.openRouterModelId = model - break - - case "gemini": - if (apiKey) config.geminiApiKey = apiKey - if (model) config.apiModelId = model - break - - case "openai-native": - if (apiKey) config.openAiNativeApiKey = apiKey - if (model) config.apiModelId = model - break - - case "openai": - if (apiKey) config.openAiApiKey = apiKey - if (model) config.openAiModelId = model - break - - case "mistral": - if (apiKey) config.mistralApiKey = apiKey - if (model) config.apiModelId = model - break - - case "deepseek": - if (apiKey) config.deepSeekApiKey = apiKey - if (model) config.apiModelId = model - break - - case "xai": - if (apiKey) config.xaiApiKey = apiKey - if (model) config.apiModelId = model - break - - case "groq": - if (apiKey) config.groqApiKey = apiKey - if (model) config.apiModelId = model - break - - case "fireworks": - if (apiKey) config.fireworksApiKey = apiKey - if (model) config.apiModelId = model - break - - case "cerebras": - if (apiKey) config.cerebrasApiKey = apiKey - if (model) config.apiModelId = model - break - - case "sambanova": - if (apiKey) config.sambaNovaApiKey = apiKey - if (model) config.apiModelId = model - break - - case "ollama": - if (apiKey) config.ollamaApiKey = apiKey - if (model) config.ollamaModelId = model - break - - case "lmstudio": - if (model) config.lmStudioModelId = model - break - - case "litellm": - if (apiKey) config.litellmApiKey = apiKey - if (model) config.litellmModelId = model - break - - case "huggingface": - if (apiKey) config.huggingFaceApiKey = apiKey - if (model) config.huggingFaceModelId = model - break - - case "chutes": - if (apiKey) config.chutesApiKey = apiKey - if (model) config.apiModelId = model - break - - case "featherless": - if (apiKey) config.featherlessApiKey = apiKey - if (model) config.apiModelId = model - break - - case "unbound": - if (apiKey) config.unboundApiKey = apiKey - if (model) config.unboundModelId = model - break - - case "requesty": - if (apiKey) config.requestyApiKey = apiKey - if (model) config.requestyModelId = model - break - - case "deepinfra": - if (apiKey) config.deepInfraApiKey = apiKey - if (model) config.deepInfraModelId = model - break - - case "vercel-ai-gateway": - if (apiKey) config.vercelAiGatewayApiKey = apiKey - if (model) config.vercelAiGatewayModelId = model - break - - case "zai": - if (apiKey) config.zaiApiKey = apiKey - if (model) config.apiModelId = model - break - - case "baseten": - if (apiKey) config.basetenApiKey = apiKey - if (model) config.apiModelId = model - break - - case "doubao": - if (apiKey) config.doubaoApiKey = apiKey - if (model) config.apiModelId = model - break - - case "moonshot": - if (apiKey) config.moonshotApiKey = apiKey - if (model) config.apiModelId = model - break - - case "minimax": - if (apiKey) config.minimaxApiKey = apiKey - if (model) config.apiModelId = model - break - - case "io-intelligence": - if (apiKey) config.ioIntelligenceApiKey = apiKey - if (model) config.ioIntelligenceModelId = model - break - - default: - // Default to apiKey and apiModelId for unknown providers. - if (apiKey) config.apiKey = apiKey - if (model) config.apiModelId = model - } - - return config - } - - /** - * Run a task with the given prompt - */ - async runTask(prompt: string): Promise { - this.log("Running task:", prompt) - - // Wait for webview to be ready - if (!this.isWebviewReady) { - this.log("Waiting for webview to be ready...") - await new Promise((resolve) => { - this.once("webviewReady", resolve) - }) - } - - // Set up message listener for extension responses - this.setupMessageListener() - - // Configure approval settings based on mode - // In non-interactive mode (-y flag), enable auto-approval for everything - // In interactive mode (default), we'll prompt the user for each action - if (this.options.nonInteractive) { - this.log("Non-interactive mode: enabling auto-approval settings...") - - const settings: RooCodeSettings = { - autoApprovalEnabled: true, - alwaysAllowReadOnly: true, - alwaysAllowReadOnlyOutsideWorkspace: true, - alwaysAllowWrite: true, - alwaysAllowWriteOutsideWorkspace: true, - alwaysAllowWriteProtected: false, // Keep protected files safe. - alwaysAllowBrowser: true, - alwaysAllowMcp: true, - alwaysAllowModeSwitch: true, - alwaysAllowSubtasks: true, - alwaysAllowExecute: true, - alwaysAllowFollowupQuestions: true, - // Allow all commands with wildcard (required for command auto-approval). - allowedCommands: ["*"], - commandExecutionTimeout: 20, - } - - this.applyRuntimeSettings(settings) - this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) - await new Promise((resolve) => setTimeout(resolve, 100)) - } else { - this.log("Interactive mode: user will be prompted for approvals...") - const settings: RooCodeSettings = { autoApprovalEnabled: false } - this.applyRuntimeSettings(settings) - this.sendToExtension({ type: "updateSettings", updatedSettings: settings }) - await new Promise((resolve) => setTimeout(resolve, 100)) - } - - if (this.options.apiKey) { - this.sendToExtension({ type: "updateSettings", updatedSettings: this.buildApiConfiguration() }) - await new Promise((resolve) => setTimeout(resolve, 100)) - } - - this.sendToExtension({ type: "newTask", text: prompt }) - await this.waitForCompletion() - } - - /** - * Set up listener for messages from the extension - */ - private setupMessageListener(): void { - this.messageListener = (message: unknown) => { - this.handleExtensionMessage(message) - } - - this.on("extensionWebviewMessage", this.messageListener) - } - - /** - * Handle messages from the extension - */ - private handleExtensionMessage(message: unknown): void { - const msg = message as Record - - if (this.options.verbose) { - this.log("Received message from extension:", JSON.stringify(msg, null, 2)) - } - - // Handle different message types - switch (msg.type) { - case "state": - this.handleStateMessage(msg) - break - - case "messageUpdated": - // This is the streaming update - handle individual message updates - this.handleMessageUpdated(msg) - break - - case "action": - this.handleActionMessage(msg) - break - - case "invoke": - this.handleInvokeMessage(msg) - break - - default: - // Log unknown message types in verbose mode - if (this.options.verbose) { - this.log("Unknown message type:", msg.type) - } - } - } - - /** - * Output a message to the user (bypasses quiet mode) - * Use this for all user-facing output instead of console.log - */ - private output(...args: unknown[]): void { - const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") - process.stdout.write(text + "\n") - } - - /** - * Output an error message to the user (bypasses quiet mode) - * Use this for all user-facing errors instead of console.error - */ - private outputError(...args: unknown[]): void { - const text = args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ") - process.stderr.write(text + "\n") - } - - /** - * Handle state update messages from the extension - */ - private handleStateMessage(msg: Record): void { - const state = msg.state as Record | undefined - if (!state) return - - const clineMessages = state.clineMessages as Array> | undefined - - if (clineMessages && clineMessages.length > 0) { - // Track message processing for verbose debug output - this.processedMessageCount++ - - // Verbose: log state update summary - if (this.options.verbose) { - this.log(`State update #${this.processedMessageCount}: ${clineMessages.length} messages`) - } - - // Process all messages to find new or updated ones - for (const message of clineMessages) { - if (!message) continue - - const ts = message.ts as number | undefined - const isPartial = message.partial as boolean | undefined - const text = message.text as string - const type = message.type as string - const say = message.say as string | undefined - const ask = message.ask as string | undefined - - if (!ts) continue - - // Handle "say" type messages - if (type === "say" && say) { - this.handleSayMessage(ts, say, text, isPartial) - } - // Handle "ask" type messages - else if (type === "ask" && ask) { - this.handleAskMessage(ts, ask, text, isPartial) - } - } - } - } - - /** - * Handle messageUpdated - individual streaming updates for a single message - * This is where real-time streaming happens! - */ - private handleMessageUpdated(msg: Record): void { - const clineMessage = msg.clineMessage as Record | undefined - if (!clineMessage) return - - const ts = clineMessage.ts as number | undefined - const isPartial = clineMessage.partial as boolean | undefined - const text = clineMessage.text as string - const type = clineMessage.type as string - const say = clineMessage.say as string | undefined - const ask = clineMessage.ask as string | undefined - - if (!ts) return - - // Handle "say" type messages - if (type === "say" && say) { - this.handleSayMessage(ts, say, text, isPartial) - } - // Handle "ask" type messages - else if (type === "ask" && ask) { - this.handleAskMessage(ts, ask, text, isPartial) - } - } - - /** - * Write streaming output directly to stdout (bypassing quiet mode if needed) - */ - private writeStream(text: string): void { - process.stdout.write(text) - } - - /** - * Stream content with delta computation - only output new characters - */ - private 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.writeStream(`\n${header} `) - this.writeStream(text) - this.streamedContent.set(ts, { text, headerShown: true }) - this.currentlyStreamingTs = ts - } else if (text.length > previous.text.length && text.startsWith(previous.text)) { - // Text has grown - output delta - const delta = text.slice(previous.text.length) - this.writeStream(delta) - this.streamedContent.set(ts, { text, headerShown: true }) - } - } - - /** - * Finish streaming a message (add newline) - */ - private finishStream(ts: number): void { - if (this.currentlyStreamingTs === ts) { - this.writeStream("\n") - this.currentlyStreamingTs = null - } - } - - /** - * Handle "say" type messages - */ - private handleSayMessage(ts: number, say: string, text: string, isPartial: boolean | undefined): void { - const previousDisplay = this.displayedMessages.get(ts) - const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial - - switch (say) { - case "text": - // Skip the initial user prompt echo (first message with no prior messages) - if (this.displayedMessages.size === 0 && !previousDisplay) { - this.displayedMessages.set(ts, { text, partial: !!isPartial }) - break - } - - if (isPartial && text) { - // Stream partial content - this.streamContent(ts, text, "[assistant]") - this.displayedMessages.set(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.writeStream(delta) - } - this.finishStream(ts) - } else { - // Not streamed yet - output complete message - this.output("\n[assistant]", text) - } - this.displayedMessages.set(ts, { text, partial: false }) - this.streamedContent.set(ts, { text, headerShown: true }) - } - break - - case "thinking": - case "reasoning": - // Stream reasoning content in real-time. - this.log(`Received ${say} message: partial=${isPartial}, textLength=${text?.length ?? 0}`) - if (isPartial && text) { - this.streamContent(ts, text, "[reasoning]") - this.displayedMessages.set(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.writeStream(delta) - } - this.finishStream(ts) - } else { - this.output("\n[reasoning]", text) - } - this.displayedMessages.set(ts, { text, partial: false }) - } - break - - case "command_output": - // Stream command output in real-time. - if (isPartial && text) { - this.streamContent(ts, text, "[command output]") - this.displayedMessages.set(ts, { text, partial: true }) - } else if (!isPartial && text && !alreadyDisplayedComplete) { - // Command output 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.writeStream(delta) - } - this.finishStream(ts) - } else { - this.writeStream("\n[command output] ") - this.writeStream(text) - this.writeStream("\n") - } - this.displayedMessages.set(ts, { text, partial: false }) - } - break - - case "completion_result": - // Only process when message is complete (not partial) - if (!isPartial && !alreadyDisplayedComplete) { - this.output("\n[task complete]", text || "") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - this.emit("taskComplete") - } else if (isPartial) { - // Track partial messages but don't output yet - wait for complete message - this.displayedMessages.set(ts, { text: text || "", partial: true }) - } - break - - case "error": - // Display errors to the user but don't terminate the task - // Errors like command timeouts are informational - the agent should decide what to do next - if (!alreadyDisplayedComplete) { - this.outputError("\n[error]", text || "Unknown error") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - case "tool": - // Tool usage - show when complete - if (text && !alreadyDisplayedComplete) { - this.output("\n[tool]", text) - this.displayedMessages.set(ts, { text, partial: false }) - } - break - - case "api_req_started": - // API request started - log in verbose mode - if (this.options.verbose) { - this.log(`API request started: ts=${ts}`) - } - break - - default: - // Other say types - show in verbose mode - if (this.options.verbose) { - this.log(`Unknown say type: ${say}, text length: ${text?.length ?? 0}, partial: ${isPartial}`) - if (text && !alreadyDisplayedComplete) { - this.output(`\n[${say}]`, text || "") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - } - } - } - - /** - * Handle "ask" type messages - these require user responses - * In interactive mode: prompt user for input - * In non-interactive mode: auto-approve (handled by extension settings) - */ - private handleAskMessage(ts: number, ask: string, text: string, isPartial: boolean | undefined): void { - // Special handling for command_output - stream it in real-time - // This needs to happen before the isPartial skip - if (ask === "command_output") { - this.handleCommandOutputAsk(ts, text, isPartial) - return - } - - // Skip partial messages - wait for the complete ask - if (isPartial) { - return - } - - // Check if we already handled this ask - if (this.pendingAsks.has(ts)) { - return - } - - // In non-interactive mode, the extension's auto-approval settings handle everything - // We just need to display the action being taken - if (this.options.nonInteractive) { - this.handleAskMessageNonInteractive(ts, ask, text) - return - } - - // Interactive mode - prompt user for input - this.handleAskMessageInteractive(ts, ask, text) - } - - /** - * Handle ask messages in non-interactive mode - * For followup questions: show prompt with 10s timeout, auto-select first option if no input - * For everything else: auto-approval handles responses - */ - private handleAskMessageNonInteractive(ts: number, ask: string, text: string): void { - const previousDisplay = this.displayedMessages.get(ts) - const alreadyDisplayed = !!previousDisplay - - switch (ask) { - case "followup": - if (!alreadyDisplayed) { - // In non-interactive mode, still prompt the user but with a 10s timeout - // that auto-selects the first option if no input is received - this.pendingAsks.add(ts) - this.handleFollowupQuestionWithTimeout(ts, text) - this.displayedMessages.set(ts, { text, partial: false }) - } - break - - case "command": - if (!alreadyDisplayed) { - this.output("\n[command]", text || "") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - // Note: command_output is handled separately in handleCommandOutputAsk - - case "tool": - if (!alreadyDisplayed && text) { - try { - const toolInfo = JSON.parse(text) - const toolName = toolInfo.tool || "unknown" - this.output(`\n[tool] ${toolName}`) - // Display all tool parameters (excluding 'tool' which is the name) - for (const [key, value] of Object.entries(toolInfo)) { - if (key === "tool") continue - // Format the value - truncate long strings - 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.output(` ${key}: ${displayValue}`) - } - } catch { - this.output("\n[tool]", text) - } - this.displayedMessages.set(ts, { text, partial: false }) - } - break - - case "browser_action_launch": - if (!alreadyDisplayed) { - this.output("\n[browser action]", text || "") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - case "use_mcp_server": - if (!alreadyDisplayed) { - try { - const mcpInfo = JSON.parse(text) - this.output(`\n[mcp] ${mcpInfo.server_name || "unknown"}`) - } catch { - this.output("\n[mcp]", text || "") - } - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - case "api_req_failed": - if (!alreadyDisplayed) { - this.output("\n[retrying api Request]") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - case "resume_task": - case "resume_completed_task": - if (!alreadyDisplayed) { - this.output("\n[continuing task]") - this.displayedMessages.set(ts, { text: text || "", partial: false }) - } - break - - case "completion_result": - // Task completion - no action needed - break - - default: - if (!alreadyDisplayed && text) { - this.output(`\n[${ask}]`, text) - this.displayedMessages.set(ts, { text, partial: false }) - } - } - } - - /** - * Handle ask messages in interactive mode - prompt user for input - */ - private handleAskMessageInteractive(ts: number, ask: string, text: string): void { - // Mark this ask as pending so we don't handle it again - this.pendingAsks.add(ts) - - switch (ask) { - case "followup": - this.handleFollowupQuestion(ts, text) - break - - case "command": - this.handleCommandApproval(ts, text) - break - - // Note: command_output is handled separately in handleCommandOutputAsk - - case "tool": - this.handleToolApproval(ts, text) - break - - case "browser_action_launch": - this.handleBrowserApproval(ts, text) - break - - case "use_mcp_server": - this.handleMcpApproval(ts, text) - break - - case "api_req_failed": - this.handleApiFailedRetry(ts, text) - break - - case "resume_task": - case "resume_completed_task": - this.handleResumeTask(ts, ask, text) - break - - case "completion_result": - // Task completion - handled by say message, no response needed - this.pendingAsks.delete(ts) - break - - default: - // Unknown ask type - try to handle as yes/no - this.handleGenericApproval(ts, ask, text) - } - } - - /** - * Handle followup questions - prompt for text input with suggestions - */ - private async handleFollowupQuestion(ts: number, text: string): Promise { - let question = text - // Suggestions are objects with { answer: string, mode?: string } - let suggestions: Array<{ answer: string; mode?: string | null }> = [] - - // Parse the followup question JSON - // Format: { question: "...", suggest: [{ answer: "text", mode: "code" }, ...] } - 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.output("\n[question]", question) - - // Show numbered suggestions - if (suggestions.length > 0) { - this.output("\nSuggested answers:") - suggestions.forEach((suggestion, index) => { - const suggestionText = suggestion.answer || String(suggestion) - const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" - this.output(` ${index + 1}. ${suggestionText}${modeHint}`) - }) - this.output("") - } - - try { - const answer = await this.promptForInput( - suggestions.length > 0 - ? "Enter number (1-" + suggestions.length + ") or type your answer: " - : "Your answer: ", - ) - - let responseText = answer.trim() - - // Check if user entered a number corresponding to a suggestion - const num = parseInt(responseText, 10) - if (!isNaN(num) && num >= 1 && num <= suggestions.length) { - const selectedSuggestion = suggestions[num - 1] - if (selectedSuggestion) { - responseText = selectedSuggestion.answer || String(selectedSuggestion) - this.output(`Selected: ${responseText}`) - } - } - - this.sendFollowupResponse(responseText) - // Don't delete from pendingAsks - keep it to prevent re-processing - // if the extension sends another state update before processing our response - } catch { - // If prompt fails (e.g., stdin closed), use first suggestion answer or empty - const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null - const fallback = firstSuggestion?.answer ?? "" - this.output(`[Using default: ${fallback || "(empty)"}]`) - this.sendFollowupResponse(fallback) - } - // Note: We intentionally don't delete from pendingAsks here. - // The ts stays in the set to prevent duplicate handling if the extension - // sends another state update before it processes our response. - // The set is cleared when the task completes or the host is disposed. - } - - /** - * Handle followup questions with a timeout (for non-interactive mode) - * Shows the prompt but auto-selects the first option after 10 seconds - * if the user doesn't type anything. Cancels the timeout on any keypress. - */ - private async handleFollowupQuestionWithTimeout(ts: number, text: string): Promise { - let question = text - // Suggestions are objects with { answer: string, mode?: string } - let suggestions: Array<{ answer: string; mode?: string | null }> = [] - - // Parse the followup question JSON - 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.output("\n[question]", question) - - // Show numbered suggestions - if (suggestions.length > 0) { - this.output("\nSuggested answers:") - suggestions.forEach((suggestion, index) => { - const suggestionText = suggestion.answer || String(suggestion) - const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" - this.output(` ${index + 1}. ${suggestionText}${modeHint}`) - }) - this.output("") - } - - // Default to first suggestion or empty string - const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null - const defaultAnswer = firstSuggestion?.answer ?? "" - - try { - const answer = await this.promptForInputWithTimeout( - suggestions.length > 0 - ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in 10s): ` - : "Your answer (auto-select in 10s): ", - 10000, // 10 second timeout - defaultAnswer, - ) - - let responseText = answer.trim() - - // Check if user entered a number corresponding to a suggestion - const num = parseInt(responseText, 10) - if (!isNaN(num) && num >= 1 && num <= suggestions.length) { - const selectedSuggestion = suggestions[num - 1] - if (selectedSuggestion) { - responseText = selectedSuggestion.answer || String(selectedSuggestion) - this.output(`Selected: ${responseText}`) - } - } - - this.sendFollowupResponse(responseText) - } catch { - // If prompt fails, use default - this.output(`[Using default: ${defaultAnswer || "(empty)"}]`) - this.sendFollowupResponse(defaultAnswer) - } - } - - /** - * Prompt user for text input with a timeout - * Returns defaultValue if timeout expires before any input - * Cancels timeout as soon as any character is typed - */ - private promptForInputWithTimeout(prompt: string, timeoutMs: number, defaultValue: string): Promise { - return new Promise((resolve) => { - // Temporarily restore console for interactive prompts - const wasQuiet = this.options.quiet - if (wasQuiet) { - this.restoreConsole() - } - - // Put stdin in raw mode to detect individual keypresses - const wasRaw = process.stdin.isRaw - if (process.stdin.isTTY) { - process.stdin.setRawMode(true) - } - process.stdin.resume() - - let inputBuffer = "" - let timeoutCancelled = false - let resolved = false - - // Set up the timeout - const timeout = setTimeout(() => { - if (!resolved) { - resolved = true - cleanup() - this.output(`\n[Timeout - using default: ${defaultValue || "(empty)"}]`) - resolve(defaultValue) - } - }, timeoutMs) - - // Show the prompt - process.stdout.write(prompt) - - // Cleanup function - const cleanup = () => { - clearTimeout(timeout) - process.stdin.removeListener("data", onData) - if (process.stdin.isTTY && wasRaw !== undefined) { - process.stdin.setRawMode(wasRaw) - } - process.stdin.pause() - if (wasQuiet) { - this.setupQuietMode() - } - } - - // Handle keypress data - const onData = (data: Buffer) => { - const char = data.toString() - - // Check for Ctrl+C - if (char === "\x03") { - cleanup() - resolved = true - this.output("\n[cancelled]") - resolve(defaultValue) - return - } - - // Cancel timeout on first character - if (!timeoutCancelled) { - timeoutCancelled = true - clearTimeout(timeout) - } - - // Handle Enter key - if (char === "\r" || char === "\n") { - if (!resolved) { - resolved = true - cleanup() - process.stdout.write("\n") - resolve(inputBuffer) - } - return - } - - // Handle Backspace - if (char === "\x7f" || char === "\b") { - if (inputBuffer.length > 0) { - inputBuffer = inputBuffer.slice(0, -1) - // Erase character on screen: move back, write space, move back - process.stdout.write("\b \b") - } - return - } - - // Regular character - add to buffer and echo - inputBuffer += char - process.stdout.write(char) - } - - process.stdin.on("data", onData) - }) - } - - /** - * Handle command execution approval - */ - private async handleCommandApproval(ts: number, text: string): Promise { - this.output("\n[command request]") - this.output(` Command: ${text || "(no command specified)"}`) - - try { - const approved = await this.promptForYesNo("Execute this command? (y/n): ") - this.sendApprovalResponse(approved) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle tool execution approval - */ - private async handleToolApproval(ts: number, text: string): Promise { - let toolName = "unknown" - let toolInfo: Record = {} - - try { - toolInfo = JSON.parse(text) as Record - toolName = (toolInfo.tool as string) || "unknown" - } catch { - // Use raw text if not JSON - } - - this.output(`\n[Tool Request] ${toolName}`) - // Display all tool parameters (excluding 'tool' which is the name) - for (const [key, value] of Object.entries(toolInfo)) { - if (key === "tool") continue - // Format the value - truncate long strings - 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.output(` ${key}: ${displayValue}`) - } - - try { - const approved = await this.promptForYesNo("Approve this action? (y/n): ") - this.sendApprovalResponse(approved) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle browser action approval - */ - private async handleBrowserApproval(ts: number, text: string): Promise { - this.output("\n[browser action request]") - if (text) this.output(` Action: ${text}`) - - try { - const approved = await this.promptForYesNo("Allow browser action? (y/n): ") - this.sendApprovalResponse(approved) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle MCP server access approval - */ - private async handleMcpApproval(ts: number, text: string): Promise { - 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.output("\n[mcp request]") - this.output(` Server: ${serverName}`) - if (toolName) this.output(` Tool: ${toolName}`) - if (resourceUri) this.output(` Resource: ${resourceUri}`) - - try { - const approved = await this.promptForYesNo("Allow MCP access? (y/n): ") - this.sendApprovalResponse(approved) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle API request failed - retry prompt - */ - private async handleApiFailedRetry(ts: number, text: string): Promise { - this.output("\n[api request failed]") - this.output(` Error: ${text || "Unknown error"}`) - - try { - const retry = await this.promptForYesNo("Retry the request? (y/n): ") - this.sendApprovalResponse(retry) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle task resume prompt - */ - private async handleResumeTask(ts: number, ask: string, text: string): Promise { - const isCompleted = ask === "resume_completed_task" - this.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) - if (text) this.output(` ${text}`) - - try { - const resume = await this.promptForYesNo("Continue with this task? (y/n): ") - this.sendApprovalResponse(resume) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle generic approval prompts for unknown ask types - */ - private async handleGenericApproval(ts: number, ask: string, text: string): Promise { - this.output(`\n[${ask}]`) - if (text) this.output(` ${text}`) - - try { - const approved = await this.promptForYesNo("Approve? (y/n): ") - this.sendApprovalResponse(approved) - } catch { - this.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - } - // Note: Don't delete from pendingAsks - see handleFollowupQuestion comment - } - - /** - * Handle command_output ask messages - stream the output in real-time - * This is called for both partial (streaming) and complete messages - */ - private handleCommandOutputAsk(ts: number, text: string, isPartial: boolean | undefined): void { - const previousDisplay = this.displayedMessages.get(ts) - const alreadyDisplayedComplete = previousDisplay && !previousDisplay.partial - - // Stream partial content - if (isPartial && text) { - this.streamContent(ts, text, "[command output]") - this.displayedMessages.set(ts, { text, partial: true }) - } else if (!isPartial) { - // Message complete - output any remaining content and send approval - if (text && !alreadyDisplayedComplete) { - 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.writeStream(delta) - } - this.finishStream(ts) - } else { - this.writeStream("\n[command output] ") - this.writeStream(text) - this.writeStream("\n") - } - this.displayedMessages.set(ts, { text, partial: false }) - this.streamedContent.set(ts, { text, headerShown: true }) - } - - // Send approval response (only once per ts). - if (!this.pendingAsks.has(ts)) { - this.pendingAsks.add(ts) - this.sendApprovalResponse(true) - } - } - } - - /** - * Prompt user for text input via readline - */ - private promptForInput(prompt: string): Promise { - return new Promise((resolve, reject) => { - // Temporarily restore console for interactive prompts - const wasQuiet = this.options.quiet - if (wasQuiet) { - this.restoreConsole() - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - rl.question(prompt, (answer) => { - rl.close() - - // Restore quiet mode if it was enabled - if (wasQuiet) { - this.setupQuietMode() - } - - resolve(answer) - }) - - // Handle stdin close (e.g., piped input ended) - rl.on("close", () => { - if (wasQuiet) { - this.setupQuietMode() - } - }) - - // Handle errors - rl.on("error", (err) => { - rl.close() - if (wasQuiet) { - this.setupQuietMode() - } - reject(err) - }) - }) - } - - /** - * Prompt user for yes/no input - */ - private async promptForYesNo(prompt: string): Promise { - const answer = await this.promptForInput(prompt) - const normalized = answer.trim().toLowerCase() - // Accept y, yes, Y, Yes, YES, etc. - return normalized === "y" || normalized === "yes" - } - - /** - * Send a followup response (text answer) to the extension - */ - private sendFollowupResponse(text: string): void { - this.sendToExtension({ - type: "askResponse", - askResponse: "messageResponse", - text, - }) - } - - /** - * Send an approval response (yes/no) to the extension - */ - private sendApprovalResponse(approved: boolean): void { - this.sendToExtension({ - type: "askResponse", - askResponse: approved ? "yesButtonClicked" : "noButtonClicked", - }) - } - - /** - * Handle action messages - */ - private handleActionMessage(msg: Record): void { - const action = msg.action as string - - if (this.options.verbose) { - this.log("Action:", action) - } - } - - /** - * Handle invoke messages - */ - private handleInvokeMessage(msg: Record): void { - const invoke = msg.invoke as string - - if (this.options.verbose) { - this.log("Invoke:", invoke) - } - } - - /** - * Wait for the task to complete - */ - private waitForCompletion(): Promise { - return new Promise((resolve, reject) => { - const completeHandler = () => { - cleanup() - resolve() - } - - const errorHandler = (error: string) => { - cleanup() - reject(new Error(error)) - } - - const cleanup = () => { - this.off("taskComplete", completeHandler) - this.off("taskError", errorHandler) - } - - this.once("taskComplete", completeHandler) - this.once("taskError", errorHandler) - - // Set a timeout (10 minutes by default) - const timeout = setTimeout( - () => { - cleanup() - reject(new Error("Task timed out")) - }, - 10 * 60 * 1000, - ) - - // Clear timeout on completion - this.once("taskComplete", () => clearTimeout(timeout)) - this.once("taskError", () => clearTimeout(timeout)) - }) - } - - /** - * Clean up resources - */ - async dispose(): Promise { - this.log("Disposing extension host...") - - // Clear pending asks - this.pendingAsks.clear() - - // Close readline interface if open - if (this.rl) { - this.rl.close() - this.rl = null - } - - // Remove message listener - if (this.messageListener) { - this.off("extensionWebviewMessage", this.messageListener) - this.messageListener = null - } - - // Deactivate extension if it has a deactivate function - if (this.extensionModule?.deactivate) { - try { - await this.extensionModule.deactivate() - } catch (error) { - this.log("Error deactivating extension:", error) - } - } - - // Clear references - this.vscode = null - this.extensionModule = null - this.extensionAPI = null - this.webviewProviders.clear() - - // Clear globals - delete (global as Record).vscode - delete (global as Record).__extensionHost - - // Restore console if it was suppressed - this.restoreConsole() - - this.log("Extension host disposed") - } -} diff --git a/apps/cli/src/extension-host/__tests__/extension-host.test.ts b/apps/cli/src/extension-host/__tests__/extension-host.test.ts new file mode 100644 index 00000000000..4c51f84a459 --- /dev/null +++ b/apps/cli/src/extension-host/__tests__/extension-host.test.ts @@ -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 = {}): ExtensionHost { + return new ExtensionHost({ + mode, + user: null, + provider, + model, + workspacePath: "/test/workspace", + extensionPath: "/test/extension", + ...options, + }) +} + +// Type for accessing private members +type PrivateHost = Record + +/** + * Helper to access private members for testing + */ +function getPrivate(host: ExtensionHost, key: string): T { + return (host as unknown as PrivateHost)[key] as T +} + +/** + * Helper to call private methods for testing + */ +function callPrivate(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).vscode + delete (global as Record).__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(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>(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>(host, "buildApiConfiguration") + + expect(config.apiProvider).toBe("openrouter") + }) + + it("should handle missing apiKey gracefully", () => { + const host = createTestHost({ + provider: "anthropic", + model: "test-model", + }) + + const config = callPrivate>(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>(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>(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(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(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).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).extensionModule = { + deactivate: deactivateMock, + } + + await host.dispose() + + expect(deactivateMock).toHaveBeenCalled() + }) + + it("should clear vscode reference", async () => { + ;(host as unknown as Record).vscode = { context: {} } + + await host.dispose() + + expect(getPrivate(host, "vscode")).toBeNull() + }) + + it("should clear extensionModule reference", async () => { + ;(host as unknown as Record).extensionModule = {} + + await host.dispose() + + expect(getPrivate(host, "extensionModule")).toBeNull() + }) + + it("should clear webviewProviders", async () => { + host.registerWebviewProvider("test", {}) + + await host.dispose() + + const providers = getPrivate>(host, "webviewProviders") + expect(providers.size).toBe(0) + }) + + it("should delete global vscode", async () => { + ;(global as Record).vscode = {} + + await host.dispose() + + expect((global as Record).vscode).toBeUndefined() + }) + + it("should delete global __extensionHost", async () => { + ;(global as Record).__extensionHost = {} + + await host.dispose() + + expect((global as Record).__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>(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>(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).currentMode = "architect" + + // Create settings object to be modified + const settings: Record = {} + 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 = {} + 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).currentMode = "debug" + + const settings: Record = {} + 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).currentMode = null + // Also clear options.mode + const options = getPrivate(host, "options") + options.mode = "" + + const settings: Record = {} + 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 = {} + 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 = {} + 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 = {} + 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(host, "options") + expect(options.ephemeral).toBe(true) + }) + + it("should default ephemeral to undefined", () => { + const host = createTestHost() + const options = getPrivate(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>(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>(host, "createEphemeralStorageDir") + const dir2 = await callPrivate>(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>(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>(host, "createEphemeralStorageDir") + ;(host as unknown as Record).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).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>(host, "createEphemeralStorageDir") + ;(host as unknown as Record).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() + }) + }) + }) +}) diff --git a/apps/cli/src/__tests__/utils.test.ts b/apps/cli/src/extension-host/__tests__/utils.test.ts similarity index 58% rename from apps/cli/src/__tests__/utils.test.ts rename to apps/cli/src/extension-host/__tests__/utils.test.ts index 34ce8254631..419a4fcaf55 100644 --- a/apps/cli/src/__tests__/utils.test.ts +++ b/apps/cli/src/extension-host/__tests__/utils.test.ts @@ -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 -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") - }) +import { getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js" - 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") - }) -}) +vi.mock("fs") 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", () => { diff --git a/apps/cli/src/extension-host/ask-dispatcher.ts b/apps/cli/src/extension-host/ask-dispatcher.ts new file mode 100644 index 00000000000..f7889c35705 --- /dev/null +++ b/apps/cli/src/extension-host/ask-dispatcher.ts @@ -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() + + 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 + */ + async handleAsk(message: ClineMessage): Promise { + // 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let toolName = "unknown" + let toolInfo: Record = {} + + try { + toolInfo = JSON.parse(text) as Record + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/cli/src/extension-host/extension-host.ts b/apps/cli/src/extension-host/extension-host.ts new file mode 100644 index 00000000000..05096f97ee7 --- /dev/null +++ b/apps/cli/src/extension-host/extension-host.ts @@ -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 + deactivate?: () => Promise +} + +interface WebviewViewProvider { + resolveWebviewView?(webviewView: unknown, context: unknown, token: unknown): void | Promise +} + +// ============================================================================= +// ExtensionHost Class +// ============================================================================= + +export class ExtensionHost extends EventEmitter { + // Extension lifecycle + private vscode: ReturnType | null = null + private extensionModule: ExtensionModule | null = null + private extensionAPI: unknown = null + private webviewProviders: Map = 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 { + 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 { + 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).vscode = this.vscode + ;(global as Record).__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) + } + + private getApiKeyFromEnv(provider: string): string | undefined { + const envVarMap: Record = { + 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 { + if (!this.isWebviewReady) { + await new Promise((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((resolve) => setTimeout(resolve, 100)) + this.sendToExtension({ type: "newTask", text: prompt }) + await this.waitForCompletion() + } + + private waitForCompletion(timeoutMs: number = 110000): Promise { + 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 { + // 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).vscode + delete (global as Record).__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 + } + } + } +} diff --git a/apps/cli/src/extension-host/index.ts b/apps/cli/src/extension-host/index.ts new file mode 100644 index 00000000000..23cbaacb4d1 --- /dev/null +++ b/apps/cli/src/extension-host/index.ts @@ -0,0 +1 @@ +export * from "./extension-host.js" diff --git a/apps/cli/src/extension-host/output-manager.ts b/apps/cli/src/extension-host/output-manager.ts new file mode 100644 index 00000000000..10ff4fe0b93 --- /dev/null +++ b/apps/cli/src/extension-host/output-manager.ts @@ -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() + + /** + * Track streamed content by ts for delta computation. + */ + private streamedContent = new Map() + + /** + * 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() + + /** + * 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 }) + } + } +} diff --git a/apps/cli/src/extension-host/prompt-manager.ts b/apps/cli/src/extension-host/prompt-manager.ts new file mode 100644 index 00000000000..40f1c38d586 --- /dev/null +++ b/apps/cli/src/extension-host/prompt-manager.ts @@ -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 { + 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 { + 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 { + 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 { + 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() + } + } +} diff --git a/apps/cli/src/utils.ts b/apps/cli/src/extension-host/utils.ts similarity index 61% rename from apps/cli/src/utils.ts rename to apps/cli/src/extension-host/utils.ts index 5ea12e33b65..4c958825d22 100644 --- a/apps/cli/src/utils.ts +++ b/apps/cli/src/extension-host/utils.ts @@ -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 = { - 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 = { + // 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 } diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 43a94f8a7d8..fad07e68769 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 { 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", -} +import { DEFAULT_FLAGS } from "./types/constants.js" -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("", "The prompt/task to execute") - .option("-w, --workspace ", "Workspace path to operate in", process.cwd()) + .argument("[workspace]", "Workspace path to operate in", process.cwd()) + .option("-P, --prompt ", "The prompt/task to execute (optional in TUI mode)") .option("-e, --extension ", "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 ", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)") + .option("-k, --api-key ", "API key for the LLM provider (defaults to OPENROUTER_API_KEY env var)") .option("-p, --provider ", "API provider (anthropic, openai, openrouter, etc.)", "openrouter") - .option("-m, --model ", "Model to use", DEFAULTS.model) - .option("-M, --mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode) + .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) + .option("-M, --mode ", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULT_FLAGS.mode) .option( "-r, --reasoning-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() diff --git a/apps/cli/src/lib/auth/index.ts b/apps/cli/src/lib/auth/index.ts new file mode 100644 index 00000000000..ec1a4598356 --- /dev/null +++ b/apps/cli/src/lib/auth/index.ts @@ -0,0 +1 @@ +export * from "./token.js" diff --git a/apps/cli/src/lib/auth/token.ts b/apps/cli/src/lib/auth/token.ts new file mode 100644 index 00000000000..34365223a47 --- /dev/null +++ b/apps/cli/src/lib/auth/token.ts @@ -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) +} diff --git a/apps/cli/src/lib/sdk/client.ts b/apps/cli/src/lib/sdk/client.ts new file mode 100644 index 00000000000..ff60e798ef6 --- /dev/null +++ b/apps/cli/src/lib/sdk/client.ts @@ -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({ + links: [ + httpBatchLink({ + url: `${url}/trpc`, + transformer: superjson, + headers: () => (authToken ? { Authorization: `Bearer ${authToken}` } : {}), + }), + ], + }) as unknown as RooClient +} diff --git a/apps/cli/src/lib/sdk/index.ts b/apps/cli/src/lib/sdk/index.ts new file mode 100644 index 00000000000..f45970d7d12 --- /dev/null +++ b/apps/cli/src/lib/sdk/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js" +export * from "./client.js" diff --git a/apps/cli/src/lib/sdk/types.ts b/apps/cli/src/lib/sdk/types.ts new file mode 100644 index 00000000000..9f0511bb2ce --- /dev/null +++ b/apps/cli/src/lib/sdk/types.ts @@ -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 + } + publicMetadata: Record + 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 +} diff --git a/apps/cli/src/lib/storage/__tests__/credentials.test.ts b/apps/cli/src/lib/storage/__tests__/credentials.test.ts new file mode 100644 index 00000000000..574b1b6bf40 --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/credentials.test.ts @@ -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) + }) + }) +}) diff --git a/apps/cli/src/lib/storage/__tests__/history.test.ts b/apps/cli/src/lib/storage/__tests__/history.test.ts new file mode 100644 index 00000000000..f928c2fb426 --- /dev/null +++ b/apps/cli/src/lib/storage/__tests__/history.test.ts @@ -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() + 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) + }) + }) +}) diff --git a/apps/cli/src/lib/storage/config-dir.ts b/apps/cli/src/lib/storage/config-dir.ts new file mode 100644 index 00000000000..6d6542ef88f --- /dev/null +++ b/apps/cli/src/lib/storage/config-dir.ts @@ -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 { + 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 + } + } +} diff --git a/apps/cli/src/lib/storage/credentials.ts b/apps/cli/src/lib/storage/credentials.ts new file mode 100644 index 00000000000..b687111c16f --- /dev/null +++ b/apps/cli/src/lib/storage/credentials.ts @@ -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 { + 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 { + 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 { + 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 { + try { + await fs.unlink(CREDENTIALS_FILE) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error + } + } +} + +export async function hasToken(): Promise { + const token = await loadToken() + return token !== null +} + +export function getCredentialsPath(): string { + return CREDENTIALS_FILE +} diff --git a/apps/cli/src/lib/storage/history.ts b/apps/cli/src/lib/storage/history.ts new file mode 100644 index 00000000000..f00a976b106 --- /dev/null +++ b/apps/cli/src/lib/storage/history.ts @@ -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 { + 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 { + 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 { + 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) +} diff --git a/apps/cli/src/lib/storage/index.ts b/apps/cli/src/lib/storage/index.ts new file mode 100644 index 00000000000..54c5da988e6 --- /dev/null +++ b/apps/cli/src/lib/storage/index.ts @@ -0,0 +1,3 @@ +export * from "./config-dir.js" +export * from "./settings.js" +export * from "./credentials.js" diff --git a/apps/cli/src/lib/storage/settings.ts b/apps/cli/src/lib/storage/settings.ts new file mode 100644 index 00000000000..c42260d9bc3 --- /dev/null +++ b/apps/cli/src/lib/storage/settings.ts @@ -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 { + 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): Promise { + 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 { + await saveSettings({ onboardingProviderChoice: undefined }) +} diff --git a/apps/cli/src/lib/utils/__tests__/commands.test.ts b/apps/cli/src/lib/utils/__tests__/commands.test.ts new file mode 100644 index 00000000000..ccae8401bda --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/commands.test.ts @@ -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") + }) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/input.test.ts b/apps/cli/src/lib/utils/__tests__/input.test.ts new file mode 100644 index 00000000000..c346e60d6d0 --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/input.test.ts @@ -0,0 +1,128 @@ +import type { Key } from "ink" + +import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../input.js" + +function createKey(overrides: Partial = {}): 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) + } + }) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/path.test.ts b/apps/cli/src/lib/utils/__tests__/path.test.ts new file mode 100644 index 00000000000..69c79ca196f --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/path.test.ts @@ -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) + }) +}) diff --git a/apps/cli/src/lib/utils/commands.ts b/apps/cli/src/lib/utils/commands.ts new file mode 100644 index 00000000000..32459e0a2a4 --- /dev/null +++ b/apps/cli/src/lib/utils/commands.ts @@ -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, + })) +} diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts new file mode 100644 index 00000000000..6112cf4dd21 --- /dev/null +++ b/apps/cli/src/lib/utils/context-window.ts @@ -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 } diff --git a/apps/cli/src/lib/utils/input.ts b/apps/cli/src/lib/utils/input.ts new file mode 100644 index 00000000000..792f38ee59d --- /dev/null +++ b/apps/cli/src/lib/utils/input.ts @@ -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 +} diff --git a/apps/cli/src/lib/utils/onboarding.ts b/apps/cli/src/lib/utils/onboarding.ts new file mode 100644 index 00000000000..92fa11f55bd --- /dev/null +++ b/apps/cli/src/lib/utils/onboarding.ts @@ -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 { + const { render } = await import("ink") + const { OnboardingScreen } = await import("../../ui/components/onboarding/index.js") + + return new Promise((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 })) + }) +} diff --git a/apps/cli/src/lib/utils/path.ts b/apps/cli/src/lib/utils/path.ts new file mode 100644 index 00000000000..ccaecd80819 --- /dev/null +++ b/apps/cli/src/lib/utils/path.ts @@ -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 +} diff --git a/apps/cli/src/lib/utils/version.ts b/apps/cli/src/lib/utils/version.ts new file mode 100644 index 00000000000..e4f2ce59b21 --- /dev/null +++ b/apps/cli/src/lib/utils/version.ts @@ -0,0 +1,6 @@ +import { createRequire } from "module" + +const require = createRequire(import.meta.url) +const packageJson = require("../package.json") + +export const VERSION = packageJson.version diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts new file mode 100644 index 00000000000..5b3dc577786 --- /dev/null +++ b/apps/cli/src/types/constants.ts @@ -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" diff --git a/apps/cli/src/types/index.ts b/apps/cli/src/types/index.ts new file mode 100644 index 00000000000..0ed3db23507 --- /dev/null +++ b/apps/cli/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./types.js" +export * from "./constants.js" diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts new file mode 100644 index 00000000000..5fcddaf836a --- /dev/null +++ b/apps/cli/src/types/types.ts @@ -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 +} diff --git a/apps/cli/src/ui/App.tsx b/apps/cli/src/ui/App.tsx new file mode 100644 index 00000000000..c0cb97d244b --- /dev/null +++ b/apps/cli/src/ui/App.tsx @@ -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 + runTask(prompt: string): Promise + sendToExtension(message: WebviewMessage): void + dispose(): Promise +} + +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>(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const followupAutocompleteRef = useRef>(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(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[] => { + 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 ( + + + Error: {error} + + + Press Ctrl+C to exit + + + ) + } + + // 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 ? ( + + ) : showExitHint ? ( + Press Ctrl+C again to exit + ) : isLoading && !pendingAsk ? ( + + {view === "ToolUse" ? "Using tool" : "Thinking"} + + Esc to cancel + {isScrollAreaActive && ( + <> + + + + )} + + ) : isScrollAreaActive ? ( + + ) : isInputAreaActive ? ( + ? for shortcuts + ) : null + + const getPickerRenderItem = () => { + if (pickerState.activeTrigger) { + return pickerState.activeTrigger.renderItem + } + + return (item: FileResult | SlashCommandResult, isSelected: boolean) => ( + + {item.key} + + ) + } + + return ( + + {/* Header - fixed size */} + +
+ + + {/* Scrollable message history area - fills remaining space via flexGrow */} + + {displayMessages.map((message) => ( + + ))} + + + {/* Input area - with borders like Claude Code - fixed size */} + + {pendingAsk?.type === "followup" ? ( + + {pendingAsk.content} + {pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? ( + + + { + onSelect(value as OnboardingProviderChoice) + }} + /> + + ) +} diff --git a/apps/cli/src/ui/components/onboarding/index.ts b/apps/cli/src/ui/components/onboarding/index.ts new file mode 100644 index 00000000000..b88ef3ce444 --- /dev/null +++ b/apps/cli/src/ui/components/onboarding/index.ts @@ -0,0 +1 @@ +export * from "./OnboardingScreen.js" diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx new file mode 100644 index 00000000000..ebbd3fb815d --- /dev/null +++ b/apps/cli/src/ui/components/tools/BrowserTool.tsx @@ -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 = { + 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 ( + + {/* Header */} + + + + {" "} + {displayName} + + {action && ( + + {" "} + → {actionLabel} + + )} + + + {/* Action details */} + + {/* URL for launch action */} + {url && ( + + url: + + {url} + + + )} + + {/* Coordinates for click/hover actions */} + {coordinate && ( + + at: + {coordinate} + + )} + + {/* Text content for type action */} + {content && action === "type" && ( + + text: + "{content}" + + )} + + {/* Key for press action */} + {content && action === "press" && ( + + key: + {content} + + )} + + + ) +} diff --git a/apps/cli/src/ui/components/tools/CommandTool.tsx b/apps/cli/src/ui/components/tools/CommandTool.tsx new file mode 100644 index 00000000000..c79a6b3d26d --- /dev/null +++ b/apps/cli/src/ui/components/tools/CommandTool.tsx @@ -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 ( + + + + {command && ( + + $ + + {command} + + + )} + + {previewOutput && ( + + + {previewOutput.split("\n").map((line, i) => ( + + {line} + + ))} + + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/CompletionTool.tsx b/apps/cli/src/ui/components/tools/CompletionTool.tsx new file mode 100644 index 00000000000..e116c76903d --- /dev/null +++ b/apps/cli/src/ui/components/tools/CompletionTool.tsx @@ -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 ? ( + + {isQuestion ? ( + + {previewContent} + + ) : ( + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + + )} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + ) : null +} diff --git a/apps/cli/src/ui/components/tools/FileReadTool.tsx b/apps/cli/src/ui/components/tools/FileReadTool.tsx new file mode 100644 index 00000000000..332b5335be5 --- /dev/null +++ b/apps/cli/src/ui/components/tools/FileReadTool.tsx @@ -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 ( + + {/* Header */} + + + + {" "} + {displayName} + + ({toolData.batchFiles.length} files) + + + {/* File list */} + + {toolData.batchFiles.slice(0, 10).map((file, index) => ( + + + {file.path} + + {file.lineSnippet && ({file.lineSnippet})} + {file.isOutsideWorkspace && ( + + {" "} + ⚠ outside workspace + + )} + + ))} + {toolData.batchFiles.length > 10 && ( + ... and {toolData.batchFiles.length - 10} more files + )} + + + ) + } + + // Single file read + const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_PREVIEW_LINES) + + return ( + + {/* Header with path on same line for single file */} + + + + {displayName} + + {path && ( + <> + · + + {path} + + {isOutsideWorkspace && ( + + {" "} + ⚠ outside workspace + + )} + + )} + + + {/* Content preview - only if we have actual file content */} + {previewContent && ( + + {isList ? ( + // Directory listing - show as tree-like structure + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + + ) : ( + // File content - show in a box + + + {previewContent} + + + )} + + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/FileWriteTool.tsx b/apps/cli/src/ui/components/tools/FileWriteTool.tsx new file mode 100644 index 00000000000..2264b65b83a --- /dev/null +++ b/apps/cli/src/ui/components/tools/FileWriteTool.tsx @@ -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 ( + + {/* Header */} + + + + {" "} + {displayName} + + ({toolData.batchDiffs.length} files) + + + {/* File list with stats */} + + {toolData.batchDiffs.slice(0, 8).map((file, index) => ( + + + {file.path} + + {file.diffStats && ( + + +{file.diffStats.added} + / + -{file.diffStats.removed} + + )} + + ))} + {toolData.batchDiffs.length > 8 && ( + ... and {toolData.batchDiffs.length - 8} more files + )} + + + ) + } + + // Single file write + const { text: previewDiff, truncated, hiddenLines } = truncateText(diff, MAX_DIFF_LINES) + const diffHunks = diff ? parseDiff(diff) : [] + + return ( + + {/* Header row with path on same line */} + + + + {displayName} + + {path && ( + <> + · + + {path} + + + )} + {isNewFile && ( + + {" "} + NEW + + )} + + {/* Diff stats badge */} + {diffStats && ( + <> + + + +{diffStats.added} + + / + + -{diffStats.removed} + + + )} + + {/* Warning badges */} + {isProtected && 🔒 protected} + {isOutsideWorkspace && ( + + {" "} + ⚠ outside workspace + + )} + + + {/* Diff preview */} + {diffHunks.length > 0 && ( + + {diffHunks.slice(0, 2).map((hunk, hunkIndex) => ( + + {/* Hunk header */} + + {hunk.header} + + + {/* Diff lines */} + {hunk.lines.slice(0, 8).map((line, lineIndex) => ( + + {line.type === "added" ? "+" : line.type === "removed" ? "-" : " "} + {line.content} + + ))} + + {hunk.lines.length > 8 && ( + + ... ({hunk.lines.length - 8} more lines in hunk) + + )} + + ))} + + {diffHunks.length > 2 && ( + + ... ({diffHunks.length - 2} more hunks) + + )} + + )} + + {/* Fallback to raw diff if no hunks parsed */} + {diffHunks.length === 0 && previewDiff && ( + + {previewDiff} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/GenericTool.tsx b/apps/cli/src/ui/components/tools/GenericTool.tsx new file mode 100644 index 00000000000..78eeaaf791d --- /dev/null +++ b/apps/cli/src/ui/components/tools/GenericTool.tsx @@ -0,0 +1,97 @@ +/** + * Generic fallback renderer for unknown tools + * Used when no specific renderer exists for a tool type + */ + +import { Box, Text } from "ink" + +import * as theme from "../../theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_CONTENT_LINES = 12 + +export function GenericTool({ toolData, rawContent }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + + // Gather all available information + const path = toolData.path + const content = toolData.content ? sanitizeContent(toolData.content) : "" + const reason = toolData.reason ? sanitizeContent(toolData.reason) : "" + const mode = toolData.mode + + // Build display content from available fields + let displayContent = content || reason || "" + + // If we have no structured content but have raw content, try to parse it + if (!displayContent && rawContent) { + try { + const parsed = JSON.parse(rawContent) + // Extract any content-like fields + displayContent = sanitizeContent(parsed.content || parsed.output || parsed.result || parsed.reason || "") + } catch { + // Use raw content as-is if not JSON + displayContent = sanitizeContent(rawContent) + } + } + + const { text: previewContent, truncated, hiddenLines } = truncateText(displayContent, MAX_CONTENT_LINES) + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + + + {/* Path if present */} + {path && ( + + path: + + {path} + + {toolData.isOutsideWorkspace && ( + + {" "} + ⚠ outside workspace + + )} + {toolData.isProtected && 🔒 protected} + + )} + + {/* Mode if present */} + {mode && ( + + mode: + + {mode} + + + )} + + {/* Content */} + {previewContent && ( + + {previewContent.split("\n").map((line, i) => ( + + {line} + + ))} + {truncated && ( + + ... ({hiddenLines} more lines) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/ModeTool.tsx b/apps/cli/src/ui/components/tools/ModeTool.tsx new file mode 100644 index 00000000000..8a7771a7ab4 --- /dev/null +++ b/apps/cli/src/ui/components/tools/ModeTool.tsx @@ -0,0 +1,28 @@ +import { Box, Text } from "ink" + +import * as theme from "../../theme.js" +import { Icon } from "../Icon.js" + +import type { ToolRendererProps } from "./types.js" +import { getToolIconName } from "./utils.js" + +export function ModeTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const mode = toolData.mode || "" + const isSwitch = toolData.tool.includes("switch") || toolData.tool.includes("Switch") + + return ( + + + {isSwitch && mode && ( + + Switching to + + {mode} + + mode + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/SearchTool.tsx b/apps/cli/src/ui/components/tools/SearchTool.tsx new file mode 100644 index 00000000000..6761cca217b --- /dev/null +++ b/apps/cli/src/ui/components/tools/SearchTool.tsx @@ -0,0 +1,117 @@ +/** + * Renderer for search operations + * Handles: searchFiles, codebaseSearch + */ + +import { Box, Text } from "ink" + +import * as theme from "../../theme.js" +import { Icon } from "../Icon.js" +import type { ToolRendererProps } from "./types.js" +import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js" + +const MAX_RESULT_LINES = 15 + +export function SearchTool({ toolData }: ToolRendererProps) { + const iconName = getToolIconName(toolData.tool) + const displayName = getToolDisplayName(toolData.tool) + const regex = toolData.regex || "" + const query = toolData.query || "" + const filePattern = toolData.filePattern || "" + const path = toolData.path || "" + const content = toolData.content ? sanitizeContent(toolData.content) : "" + + // Parse search results if content looks like results + const resultLines = content.split("\n").filter((line) => line.trim()) + const matchCount = resultLines.length + + const { text: previewContent, truncated, hiddenLines } = truncateText(content, MAX_RESULT_LINES) + + return ( + + {/* Header */} + + + + {" "} + {displayName} + + {matchCount > 0 && ({matchCount} matches)} + + + {/* Search parameters */} + + {/* Regex/Query */} + {regex && ( + + regex: + + {regex} + + + )} + {query && ( + + query: + + {query} + + + )} + + {/* Search scope */} + + {path && ( + <> + path: + {path} + + )} + {filePattern && ( + <> + pattern: + {filePattern} + + )} + + + + {/* Results */} + {previewContent && ( + + + Results: + + + {previewContent.split("\n").map((line, i) => { + // Try to highlight file:line patterns + const match = line.match(/^([^:]+):(\d+):(.*)$/) + if (match) { + const [, file, lineNum, context] = match + return ( + + {file} + : + {lineNum} + : + {context} + + ) + } + return ( + + {line} + + ) + })} + + {truncated && ( + + ... ({hiddenLines} more results) + + )} + + )} + + ) +} diff --git a/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx new file mode 100644 index 00000000000..04064e6487a --- /dev/null +++ b/apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx @@ -0,0 +1,164 @@ +import { render } from "ink-testing-library" + +import { CommandTool } from "../CommandTool.js" +import type { ToolRendererProps } from "../types.js" + +describe("CommandTool", () => { + describe("command display", () => { + it("displays the command when toolData.command is provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "npm test", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // Command should be displayed with $ prefix + expect(output).toContain("$") + expect(output).toContain("npm test") + }) + + it("does not display command section when toolData.command is empty", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The output should be displayed but no command line with $ + expect(output).toContain("All tests passed") + // Should not have a standalone $ followed by a command + // (just checking the output is present without command) + }) + + it("does not display command section when toolData.command is undefined", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + output: "All tests passed", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The output should be displayed + expect(output).toContain("All tests passed") + }) + + it("displays command with complex arguments", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: 'git commit -m "fix: resolve issue"', + output: "[main abc123] fix: resolve issue", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("$") + expect(output).toContain('git commit -m "fix: resolve issue"') + }) + }) + + describe("output display", () => { + it("displays output when provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "echo hello", + output: "hello", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("hello") + }) + + it("displays multi-line output", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "ls", + output: "file1.txt\nfile2.txt\nfile3.txt", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("file1.txt") + expect(output).toContain("file2.txt") + expect(output).toContain("file3.txt") + }) + + it("uses content as fallback when output is not provided", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "ls", + content: "fallback content", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + expect(output).toContain("fallback content") + }) + + it("truncates output to MAX_OUTPUT_LINES", () => { + // Create output with more than 10 lines (MAX_OUTPUT_LINES = 10) + const longOutput = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n") + + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "cat longfile.txt", + output: longOutput, + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // First 10 lines should be visible + expect(output).toContain("line 1") + expect(output).toContain("line 10") + + // Should show truncation indicator + expect(output).toContain("more lines") + }) + }) + + describe("header display", () => { + it("displays terminal icon when rendered", () => { + const props: ToolRendererProps = { + toolData: { + tool: "execute_command", + command: "echo test", + }, + } + + const { lastFrame } = render() + const output = lastFrame() + + // The terminal icon fallback is "$", which also appears before the command + expect(output).toContain("$") + expect(output).toContain("echo test") + }) + }) +}) diff --git a/apps/cli/src/ui/components/tools/index.ts b/apps/cli/src/ui/components/tools/index.ts new file mode 100644 index 00000000000..c6284320029 --- /dev/null +++ b/apps/cli/src/ui/components/tools/index.ts @@ -0,0 +1,63 @@ +/** + * Tool renderer components for CLI TUI + * + * Each tool type has a specialized renderer that optimizes the display + * of its unique data structure. + */ + +import type React from "react" + +import type { ToolRendererProps } from "./types.js" +import { getToolCategory } from "./types.js" + +// Import all renderers +import { FileReadTool } from "./FileReadTool.js" +import { FileWriteTool } from "./FileWriteTool.js" +import { SearchTool } from "./SearchTool.js" +import { CommandTool } from "./CommandTool.js" +import { BrowserTool } from "./BrowserTool.js" +import { ModeTool } from "./ModeTool.js" +import { CompletionTool } from "./CompletionTool.js" +import { GenericTool } from "./GenericTool.js" + +// Re-export types +export type { ToolRendererProps } from "./types.js" +export { getToolCategory } from "./types.js" + +// Re-export utilities +export * from "./utils.js" + +// Re-export individual components for direct usage +export { FileReadTool } from "./FileReadTool.js" +export { FileWriteTool } from "./FileWriteTool.js" +export { SearchTool } from "./SearchTool.js" +export { CommandTool } from "./CommandTool.js" +export { BrowserTool } from "./BrowserTool.js" +export { ModeTool } from "./ModeTool.js" +export { CompletionTool } from "./CompletionTool.js" +export { GenericTool } from "./GenericTool.js" + +/** + * Map of tool categories to their renderer components + */ +const CATEGORY_RENDERERS: Record> = { + "file-read": FileReadTool, + "file-write": FileWriteTool, + search: SearchTool, + command: CommandTool, + browser: BrowserTool, + mode: ModeTool, + completion: CompletionTool, + other: GenericTool, +} + +/** + * Get the appropriate renderer component for a tool + * + * @param toolName - The tool name/identifier + * @returns The renderer component for this tool type + */ +export function getToolRenderer(toolName: string): React.FC { + const category = getToolCategory(toolName) + return CATEGORY_RENDERERS[category] || GenericTool +} diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts new file mode 100644 index 00000000000..65c79633077 --- /dev/null +++ b/apps/cli/src/ui/components/tools/types.ts @@ -0,0 +1,65 @@ +/** + * Types for tool renderer components + */ + +import type { ToolData } from "../../types.js" + +/** + * Props passed to all tool renderer components + */ +export interface ToolRendererProps { + /** Structured tool data */ + toolData: ToolData + /** Raw content fallback (JSON string) */ + rawContent?: string +} + +/** + * Tool category for grouping similar tools + */ +export type ToolCategory = + | "file-read" + | "file-write" + | "search" + | "command" + | "browser" + | "mode" + | "completion" + | "other" + +/** + * Get the category for a tool based on its name + */ +export function getToolCategory(toolName: string): ToolCategory { + const fileReadTools = [ + "readFile", + "read_file", + "fetchInstructions", + "fetch_instructions", + "listFilesTopLevel", + "listFilesRecursive", + "list_files", + ] + const fileWriteTools = [ + "editedExistingFile", + "appliedDiff", + "apply_diff", + "newFileCreated", + "write_to_file", + "writeToFile", + ] + const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"] + const commandTools = ["execute_command", "executeCommand"] + const browserTools = ["browser_action", "browserAction"] + const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"] + const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"] + + if (fileReadTools.includes(toolName)) return "file-read" + if (fileWriteTools.includes(toolName)) return "file-write" + if (searchTools.includes(toolName)) return "search" + if (commandTools.includes(toolName)) return "command" + if (browserTools.includes(toolName)) return "browser" + if (modeTools.includes(toolName)) return "mode" + if (completionTools.includes(toolName)) return "completion" + return "other" +} diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts new file mode 100644 index 00000000000..235e7430675 --- /dev/null +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -0,0 +1,226 @@ +/** + * Utility functions for tool rendering + */ + +import type { IconName } from "../Icon.js" + +/** + * Truncate text and return truncation info + */ +export function truncateText( + text: string, + maxLines: number = 10, +): { text: string; truncated: boolean; totalLines: number; hiddenLines: number } { + const lines = text.split("\n") + const totalLines = lines.length + + if (lines.length <= maxLines) { + return { text, truncated: false, totalLines, hiddenLines: 0 } + } + + const truncatedText = lines.slice(0, maxLines).join("\n") + return { + text: truncatedText, + truncated: true, + totalLines, + hiddenLines: totalLines - maxLines, + } +} + +/** + * Sanitize content for terminal display + * - Replaces tabs with spaces + * - Strips carriage returns + */ +export function sanitizeContent(text: string): string { + return text.replace(/\t/g, " ").replace(/\r/g, "") +} + +/** + * Format diff stats as a colored string representation + */ +export function formatDiffStats(stats: { added: number; removed: number }): { added: string; removed: string } { + return { + added: `+${stats.added}`, + removed: `-${stats.removed}`, + } +} + +/** + * Get a friendly display name for a tool + */ +export function getToolDisplayName(toolName: string): string { + const displayNames: Record = { + // File read operations + readFile: "Read", + read_file: "Read", + fetchInstructions: "Fetch Instructions", + fetch_instructions: "Fetch Instructions", + listFilesTopLevel: "List Files", + listFilesRecursive: "List Files (Recursive)", + list_files: "List Files", + + // File write operations + editedExistingFile: "Edit", + appliedDiff: "Diff", + apply_diff: "Diff", + newFileCreated: "Create File", + write_to_file: "Write File", + writeToFile: "Write File", + + // Search operations + searchFiles: "Search Files", + search_files: "Search Files", + codebaseSearch: "Codebase Search", + codebase_search: "Codebase Search", + + // Command operations + execute_command: "Execute Command", + executeCommand: "Execute Command", + + // Browser operations + browser_action: "Browser Action", + browserAction: "Browser Action", + + // Mode operations + switchMode: "Switch Mode", + switch_mode: "Switch Mode", + newTask: "New Task", + new_task: "New Task", + finishTask: "Finish Task", + + // Completion operations + attempt_completion: "Task Complete", + attemptCompletion: "Task Complete", + ask_followup_question: "Question", + askFollowupQuestion: "Question", + + // TODO operations + update_todo_list: "Update TODO List", + updateTodoList: "Update TODO List", + } + + return displayNames[toolName] || toolName +} + +/** + * Get the IconName for a tool (for use with Icon component) + */ +export function getToolIconName(toolName: string): IconName { + const iconNames: Record = { + // File read operations + readFile: "file", + read_file: "file", + fetchInstructions: "file", + fetch_instructions: "file", + listFilesTopLevel: "folder", + listFilesRecursive: "folder", + list_files: "folder", + + // File write operations + editedExistingFile: "file-edit", + appliedDiff: "diff", + apply_diff: "diff", + newFileCreated: "file-edit", + write_to_file: "file-edit", + writeToFile: "file-edit", + + // Search operations + searchFiles: "search", + search_files: "search", + codebaseSearch: "search", + codebase_search: "search", + + // Command operations + execute_command: "terminal", + executeCommand: "terminal", + + // Browser operations + browser_action: "browser", + browserAction: "browser", + + // Mode operations + switchMode: "switch", + switch_mode: "switch", + newTask: "switch", + new_task: "switch", + finishTask: "check", + + // Completion operations + attempt_completion: "check", + attemptCompletion: "check", + ask_followup_question: "question", + askFollowupQuestion: "question", + + // TODO operations + update_todo_list: "check", + updateTodoList: "check", + } + + return iconNames[toolName] || "gear" +} + +/** + * Format a file path for display, optionally with workspace indicator + */ +export function formatPath(path: string, isOutsideWorkspace?: boolean, isProtected?: boolean): string { + let result = path + const badges: string[] = [] + + if (isOutsideWorkspace) { + badges.push("outside workspace") + } + + if (isProtected) { + badges.push("protected") + } + + if (badges.length > 0) { + result += ` (${badges.join(", ")})` + } + + return result +} + +/** + * Parse diff content into structured hunks for rendering + */ +export interface DiffHunk { + header: string + lines: Array<{ + type: "context" | "added" | "removed" | "header" + content: string + lineNumber?: number + }> +} + +export function parseDiff(diffContent: string): DiffHunk[] { + const hunks: DiffHunk[] = [] + const lines = diffContent.split("\n") + + let currentHunk: DiffHunk | null = null + + for (const line of lines) { + if (line.startsWith("@@")) { + // New hunk header + if (currentHunk) { + hunks.push(currentHunk) + } + currentHunk = { header: line, lines: [] } + } else if (currentHunk) { + if (line.startsWith("+") && !line.startsWith("+++")) { + currentHunk.lines.push({ type: "added", content: line.substring(1) }) + } else if (line.startsWith("-") && !line.startsWith("---")) { + currentHunk.lines.push({ type: "removed", content: line.substring(1) }) + } else if (line.startsWith(" ") || line === "") { + currentHunk.lines.push({ type: "context", content: line.substring(1) || "" }) + } + } + } + + if (currentHunk) { + hunks.push(currentHunk) + } + + return hunks +} diff --git a/apps/cli/src/ui/hooks/TerminalSizeContext.tsx b/apps/cli/src/ui/hooks/TerminalSizeContext.tsx new file mode 100644 index 00000000000..c8718abb97c --- /dev/null +++ b/apps/cli/src/ui/hooks/TerminalSizeContext.tsx @@ -0,0 +1,38 @@ +/** + * TerminalSizeContext - Provides terminal dimensions via React Context + * This ensures only one instance of useTerminalSize exists in the app + */ + +import { createContext, useContext, ReactNode } from "react" +import { useTerminalSize as useTerminalSizeHook } from "./useTerminalSize.js" + +interface TerminalSizeContextValue { + columns: number + rows: number +} + +const TerminalSizeContext = createContext(null) + +interface TerminalSizeProviderProps { + children: ReactNode +} + +/** + * Provider component that wraps the app and provides terminal size to all children + */ +export function TerminalSizeProvider({ children }: TerminalSizeProviderProps) { + const size = useTerminalSizeHook() + return {children} +} + +/** + * Hook to access terminal size from context + * Must be used within a TerminalSizeProvider + */ +export function useTerminalSize(): TerminalSizeContextValue { + const context = useContext(TerminalSizeContext) + if (!context) { + throw new Error("useTerminalSize must be used within a TerminalSizeProvider") + } + return context +} diff --git a/apps/cli/src/ui/hooks/__tests__/useToast.test.ts b/apps/cli/src/ui/hooks/__tests__/useToast.test.ts new file mode 100644 index 00000000000..67729d09f8d --- /dev/null +++ b/apps/cli/src/ui/hooks/__tests__/useToast.test.ts @@ -0,0 +1,190 @@ +import { useToastStore } from "../useToast.js" + +describe("useToastStore", () => { + beforeEach(() => { + // Reset the store before each test + useToastStore.setState({ toasts: [] }) + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("initial state", () => { + it("should start with an empty toast queue", () => { + const state = useToastStore.getState() + expect(state.toasts).toEqual([]) + }) + }) + + describe("addToast", () => { + it("should add a toast to the queue", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Test message") + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]).toMatchObject({ + id, + message: "Test message", + type: "info", + duration: 3000, + }) + }) + + it("should add a toast with custom type", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Error message", "error") + + const state = useToastStore.getState() + expect(state.toasts[0]).toMatchObject({ + id, + message: "Error message", + type: "error", + }) + }) + + it("should add a toast with custom duration", () => { + const { addToast } = useToastStore.getState() + + const id = addToast("Custom duration", "info", 5000) + + const state = useToastStore.getState() + expect(state.toasts[0]).toMatchObject({ + id, + duration: 5000, + }) + }) + + it("should replace existing toast when adding a new one (immediate display)", () => { + const { addToast } = useToastStore.getState() + + addToast("First message") + addToast("Second message") + addToast("Third message") + + const state = useToastStore.getState() + // New toasts replace existing ones for immediate display + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]?.message).toBe("Third message") + }) + + it("should generate unique IDs for each toast", () => { + const { addToast } = useToastStore.getState() + + const id1 = addToast("First") + const id2 = addToast("Second") + const id3 = addToast("Third") + + expect(id1).not.toBe(id2) + expect(id2).not.toBe(id3) + expect(id1).not.toBe(id3) + }) + + it("should set createdAt timestamp", () => { + const { addToast } = useToastStore.getState() + const beforeTime = Date.now() + + addToast("Timestamped message") + + const state = useToastStore.getState() + expect(state.toasts[0]?.createdAt).toBeGreaterThanOrEqual(beforeTime) + expect(state.toasts[0]?.createdAt).toBeLessThanOrEqual(Date.now()) + }) + + it("should support success type", () => { + const { addToast } = useToastStore.getState() + + addToast("Success", "success") + + const state = useToastStore.getState() + expect(state.toasts[0]?.type).toBe("success") + }) + + it("should support warning type", () => { + const { addToast } = useToastStore.getState() + + addToast("Warning", "warning") + + const state = useToastStore.getState() + expect(state.toasts[0]?.type).toBe("warning") + }) + }) + + describe("removeToast", () => { + it("should remove a toast by ID", () => { + const { addToast, removeToast } = useToastStore.getState() + + const id = addToast("Only toast") + + removeToast(id) + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + + it("should handle removing non-existent toast gracefully", () => { + const { addToast, removeToast } = useToastStore.getState() + + addToast("Only toast") + + removeToast("non-existent-id") + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(1) + }) + }) + + describe("clearToasts", () => { + it("should clear all toasts", () => { + const { addToast, clearToasts } = useToastStore.getState() + + addToast("First") + addToast("Second") + addToast("Third") + + clearToasts() + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + + it("should handle clearing empty queue", () => { + const { clearToasts } = useToastStore.getState() + + clearToasts() + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + }) + + describe("immediate replacement behavior", () => { + it("should show latest toast immediately when multiple are added", () => { + const { addToast } = useToastStore.getState() + + addToast("First") + addToast("Second") + const id3 = addToast("Third") + + const state = useToastStore.getState() + // Only most recent toast is present + expect(state.toasts).toHaveLength(1) + expect(state.toasts[0]?.id).toBe(id3) + expect(state.toasts[0]?.message).toBe("Third") + }) + + it("should return empty when toast is removed", () => { + const { addToast, removeToast } = useToastStore.getState() + + const id = addToast("Only toast") + removeToast(id) + + const state = useToastStore.getState() + expect(state.toasts).toHaveLength(0) + }) + }) +}) diff --git a/apps/cli/src/ui/hooks/index.ts b/apps/cli/src/ui/hooks/index.ts new file mode 100644 index 00000000000..9e12cd9b0e7 --- /dev/null +++ b/apps/cli/src/ui/hooks/index.ts @@ -0,0 +1,22 @@ +// Export existing hooks +export { TerminalSizeProvider, useTerminalSize } from "./TerminalSizeContext.js" +export { useToast, useToastStore } from "./useToast.js" +export { useInputHistory } from "./useInputHistory.js" + +// Export new extracted hooks +export { useFollowupCountdown } from "./useFollowupCountdown.js" +export { useFocusManagement } from "./useFocusManagement.js" +export { useMessageHandlers } from "./useMessageHandlers.js" +export { useExtensionHost } from "./useExtensionHost.js" +export { useTaskSubmit } from "./useTaskSubmit.js" +export { useGlobalInput } from "./useGlobalInput.js" +export { usePickerHandlers } from "./usePickerHandlers.js" + +// Export types +export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js" +export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js" +export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js" +export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js" +export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js" +export type { UseGlobalInputOptions } from "./useGlobalInput.js" +export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js" diff --git a/apps/cli/src/ui/hooks/useExtensionHost.ts b/apps/cli/src/ui/hooks/useExtensionHost.ts new file mode 100644 index 00000000000..35770f0480b --- /dev/null +++ b/apps/cli/src/ui/hooks/useExtensionHost.ts @@ -0,0 +1,163 @@ +import { useEffect, useRef, useCallback, useMemo } from "react" +import { useApp } from "ink" +import { randomUUID } from "crypto" +import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" + +import { useCLIStore } from "../store.js" + +import { ExtensionHostOptions } from "../../extension-host/extension-host.js" + +interface ExtensionHostInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): void + activate(): Promise + runTask(prompt: string): Promise + sendToExtension(message: WebviewMessage): void + dispose(): Promise +} + +export interface UseExtensionHostOptions extends ExtensionHostOptions { + initialPrompt?: string + exitOnComplete?: boolean + onExtensionMessage: (msg: ExtensionMessage) => void + createExtensionHost: (options: ExtensionHostOptions) => ExtensionHostInterface +} + +export interface UseExtensionHostReturn { + isReady: boolean + sendToExtension: ((msg: WebviewMessage) => void) | null + runTask: ((prompt: string) => Promise) | null + cleanup: () => Promise +} + +/** + * Hook to manage the extension host lifecycle. + * + * Responsibilities: + * - Initialize the extension host + * - Set up event listeners for messages, task completion, and errors + * - Handle cleanup/disposal + * - Expose methods for sending messages and running tasks + */ +export function useExtensionHost({ + initialPrompt, + mode, + reasoningEffort, + user, + provider, + apiKey, + model, + workspacePath, + extensionPath, + nonInteractive, + ephemeral, + exitOnComplete, + onExtensionMessage, + createExtensionHost, +}: UseExtensionHostOptions): UseExtensionHostReturn { + const { exit } = useApp() + const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore() + + const hostRef = useRef(null) + const isReadyRef = useRef(false) + + const cleanup = useCallback(async () => { + if (hostRef.current) { + await hostRef.current.dispose() + hostRef.current = null + isReadyRef.current = false + } + }, []) + + useEffect(() => { + const init = async () => { + try { + const host = createExtensionHost({ + mode, + user, + reasoningEffort, + provider, + apiKey, + model, + workspacePath, + extensionPath, + nonInteractive, + disableOutput: true, + ephemeral, + }) + + hostRef.current = host + isReadyRef.current = true + + host.on("extensionWebviewMessage", onExtensionMessage) + + host.on("taskComplete", async () => { + setComplete(true) + setLoading(false) + + if (exitOnComplete) { + await cleanup() + exit() + setTimeout(() => process.exit(0), 100) + } + }) + + host.on("taskError", (err: string) => { + setError(err) + setLoading(false) + }) + + await host.activate() + + // Request initial state from extension (triggers + // postStateToWebview which includes taskHistory). + host.sendToExtension({ type: "webviewDidLaunch" }) + host.sendToExtension({ type: "requestCommands" }) + host.sendToExtension({ type: "requestModes" }) + + setLoading(false) + + if (initialPrompt) { + setHasStartedTask(true) + setLoading(true) + addMessage({ id: randomUUID(), role: "user", content: initialPrompt }) + await host.runTask(initialPrompt) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } + + init() + + return () => { + cleanup() + } + }, []) // Run once on mount + + // Stable sendToExtension - uses ref to always access current host + // This function reference never changes, preventing downstream useCallback/useMemo invalidations + const sendToExtension = useCallback((msg: WebviewMessage) => { + hostRef.current?.sendToExtension(msg) + }, []) + + // Stable runTask - uses ref to always access current host + const runTask = useCallback((prompt: string): Promise => { + if (!hostRef.current) { + return Promise.reject(new Error("Extension host not ready")) + } + return hostRef.current.runTask(prompt) + }, []) + + // Memoized return object to prevent unnecessary re-renders in consumers + return useMemo( + () => ({ + isReady: isReadyRef.current, + sendToExtension, + runTask, + cleanup, + }), + [sendToExtension, runTask, cleanup], + ) +} diff --git a/apps/cli/src/ui/hooks/useFocusManagement.ts b/apps/cli/src/ui/hooks/useFocusManagement.ts new file mode 100644 index 00000000000..dd0b30c61c0 --- /dev/null +++ b/apps/cli/src/ui/hooks/useFocusManagement.ts @@ -0,0 +1,85 @@ +import { useEffect } from "react" +import { useUIStateStore } from "../stores/uiStateStore.js" +import type { PendingAsk } from "../types.js" + +export interface UseFocusManagementOptions { + showApprovalPrompt: boolean + pendingAsk: PendingAsk | null +} + +export interface UseFocusManagementReturn { + /** Whether focus can be toggled between scroll and input areas */ + canToggleFocus: boolean + /** Whether scroll area should capture keyboard input */ + isScrollAreaActive: boolean + /** Whether input area is active (for visual focus indicator) */ + isInputAreaActive: boolean + /** Manual focus override */ + manualFocus: "scroll" | "input" | null + /** Set manual focus override */ + setManualFocus: (focus: "scroll" | "input" | null) => void + /** Toggle focus between scroll and input */ + toggleFocus: () => void +} + +/** + * Hook to manage focus state between scroll area and input area. + * + * Focus can be toggled when text input is available (not showing approval prompt). + * The hook automatically resets manual focus when the view changes. + */ +export function useFocusManagement({ + showApprovalPrompt, + pendingAsk, +}: UseFocusManagementOptions): UseFocusManagementReturn { + const { showCustomInput, manualFocus, setManualFocus } = useUIStateStore() + + // Determine if we're in a mode where focus can be toggled (text input is available) + const canToggleFocus = + !showApprovalPrompt && + (!pendingAsk || // Initial input or task complete or loading + pendingAsk.type === "followup" || // Followup question with suggestions or custom input + showCustomInput) // Custom input mode + + // Determine if scroll area should capture keyboard input + const isScrollAreaActive: boolean = + manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt) + + // Determine if input area is active (for visual focus indicator) + const isInputAreaActive: boolean = + manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt + + // Reset manual focus when view changes (e.g., agent starts responding) + useEffect(() => { + if (!canToggleFocus) { + setManualFocus(null) + } + }, [canToggleFocus, setManualFocus]) + + /** + * Toggle focus between scroll and input areas + */ + const toggleFocus = () => { + if (!canToggleFocus) { + return + } + + const prev = manualFocus + if (prev === "scroll") { + setManualFocus("input") + } else if (prev === "input") { + setManualFocus("scroll") + } else { + setManualFocus(isScrollAreaActive ? "input" : "scroll") + } + } + + return { + canToggleFocus, + isScrollAreaActive, + isInputAreaActive, + manualFocus, + setManualFocus, + toggleFocus, + } +} diff --git a/apps/cli/src/ui/hooks/useFollowupCountdown.ts b/apps/cli/src/ui/hooks/useFollowupCountdown.ts new file mode 100644 index 00000000000..c46f2c7774a --- /dev/null +++ b/apps/cli/src/ui/hooks/useFollowupCountdown.ts @@ -0,0 +1,112 @@ +import { useEffect, useRef } from "react" +import { FOLLOWUP_TIMEOUT_SECONDS } from "../../types/constants.js" +import { useUIStateStore } from "../stores/uiStateStore.js" +import type { PendingAsk } from "../types.js" + +export interface UseFollowupCountdownOptions { + pendingAsk: PendingAsk | null + onAutoSubmit: (text: string) => void +} + +/** + * Hook to manage auto-accept countdown timer for followup questions with suggestions. + * + * When a followup question appears with suggestions (and not in custom input mode), + * starts a countdown timer that auto-submits the first suggestion when it reaches zero. + * + * The countdown can be canceled by: + * - User navigating with arrow keys + * - User switching to custom input mode + * - Followup question changing/disappearing + */ +export function useFollowupCountdown({ pendingAsk, onAutoSubmit }: UseFollowupCountdownOptions) { + const { showCustomInput, countdownSeconds, setCountdownSeconds } = useUIStateStore() + const countdownIntervalRef = useRef(null) + + // Use ref for onAutoSubmit to avoid stale closure issues without needing it in dependencies + const onAutoSubmitRef = useRef(onAutoSubmit) + useEffect(() => { + onAutoSubmitRef.current = onAutoSubmit + }, [onAutoSubmit]) + + // Cleanup interval on unmount + useEffect(() => { + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + } + } + }, []) + + // Start countdown when a followup question with suggestions appears + useEffect(() => { + // Clear any existing countdown + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + + // Only start countdown for followup questions with suggestions (not custom input mode) + if ( + pendingAsk?.type === "followup" && + pendingAsk.suggestions && + pendingAsk.suggestions.length > 0 && + !showCustomInput + ) { + // Start countdown + setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS) + + countdownIntervalRef.current = setInterval(() => { + const currentSeconds = useUIStateStore.getState().countdownSeconds + if (currentSeconds === null || currentSeconds <= 1) { + // Time's up! Auto-select first option + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) + // Auto-submit the first suggestion + if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) { + const firstSuggestion = pendingAsk.suggestions[0] + if (firstSuggestion) { + onAutoSubmitRef.current(firstSuggestion.answer) + } + } + } else { + setCountdownSeconds(currentSeconds - 1) + } + }, 1000) + } else { + // Only set to null if not already null to prevent unnecessary state updates + // This is critical to avoid infinite render loops + if (countdownSeconds !== null) { + setCountdownSeconds(null) + } + } + + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + } + // Note: countdownSeconds is intentionally NOT in deps - we only read it to avoid + // unnecessary state updates, not to react to its changes + }, [pendingAsk?.id, pendingAsk?.type, showCustomInput, setCountdownSeconds]) + + /** + * Cancel the countdown timer (called when user interacts with the menu) + */ + const cancelCountdown = () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + setCountdownSeconds(null) + } + + return { + countdownSeconds, + cancelCountdown, + } +} diff --git a/apps/cli/src/ui/hooks/useGlobalInput.ts b/apps/cli/src/ui/hooks/useGlobalInput.ts new file mode 100644 index 00000000000..31b2deb71d0 --- /dev/null +++ b/apps/cli/src/ui/hooks/useGlobalInput.ts @@ -0,0 +1,170 @@ +import { useEffect, useRef } from "react" +import { useInput } from "ink" +import type { WebviewMessage } from "@roo-code/types" + +import { matchesGlobalSequence } from "../../lib/utils/input.js" + +import type { ModeResult } from "../components/autocomplete/index.js" +import { useUIStateStore } from "../stores/uiStateStore.js" +import { useCLIStore } from "../store.js" + +export interface UseGlobalInputOptions { + canToggleFocus: boolean + isScrollAreaActive: boolean + pickerIsOpen: boolean + availableModes: ModeResult[] + currentMode: string | null + mode: string + sendToExtension: ((msg: WebviewMessage) => void) | null + showInfo: (msg: string, duration?: number) => void + exit: () => void + cleanup: () => Promise + toggleFocus: () => void + closePicker: () => void +} + +/** + * Hook to handle global keyboard shortcuts. + * + * Shortcuts: + * - Ctrl+C: Double-press to exit + * - Tab: Toggle focus between scroll area and input + * - Ctrl+M: Cycle through available modes + * - Ctrl+T: Toggle TODO list viewer + * - Escape: Cancel task (when loading) or close TODO viewer + */ +export function useGlobalInput({ + canToggleFocus, + isScrollAreaActive: _isScrollAreaActive, + pickerIsOpen, + availableModes, + currentMode, + mode, + sendToExtension, + showInfo, + exit, + cleanup, + toggleFocus, + closePicker, +}: UseGlobalInputOptions): void { + const { isLoading, currentTodos } = useCLIStore() + const { + showTodoViewer, + setShowTodoViewer, + showExitHint: _showExitHint, + setShowExitHint, + pendingExit, + setPendingExit, + } = useUIStateStore() + + // Track Ctrl+C presses for "press again to exit" behavior + const exitHintTimeout = useRef(null) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + } + }, []) + + // Handle global keyboard shortcuts + useInput((input, key) => { + // Tab to toggle focus between scroll area and input (only when input is available) + if (key.tab && canToggleFocus && !pickerIsOpen) { + toggleFocus() + return + } + + // Ctrl+M to cycle through modes (only when not loading and we have available modes) + // Uses centralized global input sequence detection + if (matchesGlobalSequence(input, key, "ctrl-m")) { + // Don't allow mode switching while a task is in progress (loading) + if (isLoading) { + showInfo("Cannot switch modes while task is in progress", 2000) + return + } + + // Need at least 2 modes to cycle + if (availableModes.length < 2) { + return + } + + // Find current mode index + const currentModeSlug = currentMode || mode + const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length + const nextMode = availableModes[nextIndex] + + if (nextMode && sendToExtension) { + sendToExtension({ type: "mode", text: nextMode.slug }) + showInfo(`Switched to ${nextMode.name}`, 2000) + } + + return + } + + // Ctrl+T to toggle TODO list viewer + if (matchesGlobalSequence(input, key, "ctrl-t")) { + // Close picker if open + if (pickerIsOpen) { + closePicker() + } + // Toggle TODO viewer + setShowTodoViewer(!showTodoViewer) + if (!showTodoViewer && currentTodos.length === 0) { + showInfo("No TODO list available", 2000) + setShowTodoViewer(false) + } + return + } + + // Escape key to close TODO viewer + if (key.escape && showTodoViewer) { + setShowTodoViewer(false) + return + } + + // Escape key to cancel/pause task when loading (streaming) + if (key.escape && isLoading && sendToExtension) { + // If picker is open, let the picker handle escape first + if (pickerIsOpen) { + return + } + // Send cancel message to extension (same as webview-ui Cancel button) + sendToExtension({ type: "cancelTask" }) + return + } + + // Ctrl+C to exit + if (key.ctrl && input === "c") { + // If picker is open, close it first + if (pickerIsOpen) { + closePicker() + return + } + + if (pendingExit) { + // Second press - exit immediately + if (exitHintTimeout.current) { + clearTimeout(exitHintTimeout.current) + } + cleanup().finally(() => { + exit() + process.exit(0) + }) + } else { + // First press - show hint and wait for second press + setPendingExit(true) + setShowExitHint(true) + + exitHintTimeout.current = setTimeout(() => { + setPendingExit(false) + setShowExitHint(false) + exitHintTimeout.current = null + }, 2000) + } + } + }) +} diff --git a/apps/cli/src/ui/hooks/useInputHistory.ts b/apps/cli/src/ui/hooks/useInputHistory.ts new file mode 100644 index 00000000000..a30be52c397 --- /dev/null +++ b/apps/cli/src/ui/hooks/useInputHistory.ts @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback, useRef } from "react" + +import { loadHistory, addToHistory } from "../../lib/storage/history.js" + +export interface UseInputHistoryOptions { + isActive?: boolean + getCurrentInput?: () => string +} + +export interface UseInputHistoryReturn { + addEntry: (entry: string) => Promise + historyValue: string | null + isBrowsing: boolean + resetBrowsing: (currentInput?: string) => void + history: string[] + draft: string + setDraft: (value: string) => void + navigateUp: () => void + navigateDown: () => void +} + +export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputHistoryReturn { + const { isActive = true, getCurrentInput } = options + + // All history entries (oldest first, newest at end) + const [history, setHistory] = useState([]) + + // Current position in history (-1 = not browsing, 0 = oldest, history.length-1 = newest) + const [historyIndex, setHistoryIndex] = useState(-1) + + // The user's typed text before they started navigating history + const [draft, setDraft] = useState("") + + // Flag to track if history has been loaded + const historyLoaded = useRef(false) + + // Load history on mount + useEffect(() => { + if (!historyLoaded.current) { + historyLoaded.current = true + loadHistory() + .then(setHistory) + .catch(() => { + // Ignore load errors - history is not critical + }) + } + }, []) + + // Navigate to older history entry + const navigateUp = useCallback(() => { + if (!isActive) return + if (history.length === 0) return + + if (historyIndex === -1) { + // Starting to browse - save current input as draft + if (getCurrentInput) { + setDraft(getCurrentInput()) + } + // Go to newest entry + setHistoryIndex(history.length - 1) + } else if (historyIndex > 0) { + // Go to older entry + setHistoryIndex(historyIndex - 1) + } + // At oldest entry - stay there + }, [isActive, history, historyIndex, getCurrentInput]) + + // Navigate to newer history entry + const navigateDown = useCallback(() => { + if (!isActive) return + if (historyIndex === -1) return // Not browsing + + if (historyIndex < history.length - 1) { + // Go to newer entry + setHistoryIndex(historyIndex + 1) + } else { + // At newest entry - return to draft + setHistoryIndex(-1) + } + }, [isActive, historyIndex, history.length]) + + // Add new entry to history + const addEntry = useCallback(async (entry: string) => { + const trimmed = entry.trim() + if (!trimmed) return + + try { + const updated = await addToHistory(trimmed) + setHistory(updated) + } catch { + // Ignore save errors - history is not critical + } + + // Reset navigation state + setHistoryIndex(-1) + setDraft("") + }, []) + + // Reset browsing state + const resetBrowsing = useCallback((currentInput?: string) => { + setHistoryIndex(-1) + if (currentInput !== undefined) { + setDraft(currentInput) + } + }, []) + + // Calculate the current history value to display + // When browsing, show history entry; when returning from browsing, show draft + let historyValue: string | null = null + if (historyIndex >= 0 && historyIndex < history.length) { + historyValue = history[historyIndex] ?? null + } + + const isBrowsing = historyIndex !== -1 + + return { + addEntry, + historyValue, + isBrowsing, + resetBrowsing, + history, + draft, + setDraft, + navigateUp, + navigateDown, + } +} diff --git a/apps/cli/src/ui/hooks/useMessageHandlers.ts b/apps/cli/src/ui/hooks/useMessageHandlers.ts new file mode 100644 index 00000000000..68695e39c74 --- /dev/null +++ b/apps/cli/src/ui/hooks/useMessageHandlers.ts @@ -0,0 +1,410 @@ +import { useCallback, useRef } from "react" +import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types" +import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/cli" + +import type { TUIMessage, ToolData } from "../types.js" +import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js" +import { useCLIStore } from "../store.js" +import { extractToolData, formatToolOutput, formatToolAskMessage, parseTodosFromToolInfo } from "../utils/tools.js" + +export interface UseMessageHandlersOptions { + nonInteractive: boolean +} + +export interface UseMessageHandlersReturn { + handleExtensionMessage: (msg: ExtensionMessage) => void + seenMessageIds: React.MutableRefObject> + pendingCommandRef: React.MutableRefObject + firstTextMessageSkipped: React.MutableRefObject +} + +/** + * Hook to handle messages from the extension. + * + * Processes three types of messages: + * 1. "say" messages - Information from the agent (text, tool output, reasoning) + * 2. "ask" messages - Requests for user input (approvals, followup questions) + * 3. Extension state updates - Mode changes, task history, file search results + * + * Transforms ClineMessage format to TUIMessage format and updates the store. + */ +export function useMessageHandlers({ nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn { + const { + addMessage, + setPendingAsk, + setComplete, + setLoading, + setHasStartedTask, + setFileSearchResults, + setAllSlashCommands, + setAvailableModes, + setCurrentMode, + setTokenUsage, + setRouterModels, + setTaskHistory, + currentTodos, + setTodos, + } = useCLIStore() + + // Track seen message timestamps to filter duplicates and the prompt echo + const seenMessageIds = useRef>(new Set()) + const firstTextMessageSkipped = useRef(false) + + // Track pending command for injecting into command_output toolData + const pendingCommandRef = useRef(null) + + /** + * Map extension "say" messages to TUI messages + */ + const handleSayMessage = useCallback( + (ts: number, say: ClineSay, text: string, partial: boolean) => { + const messageId = ts.toString() + const isResuming = useCLIStore.getState().isResumingTask + + if (say === "checkpoint_saved") { + return + } + + if (say === "api_req_started") { + return + } + + if (say === "user_feedback") { + seenMessageIds.current.add(messageId) + return + } + + // Skip first text message ONLY for new tasks, not resumed tasks + // When resuming, we want to show all historical messages including the first one + if (say === "text" && !firstTextMessageSkipped.current && !isResuming) { + firstTextMessageSkipped.current = true + seenMessageIds.current.add(messageId) + return + } + + if (seenMessageIds.current.has(messageId) && !partial) { + return + } + + let role: TUIMessage["role"] = "assistant" + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let toolData: ToolData | undefined + + if (say === "command_output") { + role = "tool" + toolName = "execute_command" + toolDisplayName = "bash" + toolDisplayOutput = text + const trackedCommand = pendingCommandRef.current + toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text } + pendingCommandRef.current = null + } else if (say === "reasoning") { + role = "thinking" + } + + seenMessageIds.current.add(messageId) + + addMessage({ + id: messageId, + role, + content: text || "", + toolName, + toolDisplayName, + toolDisplayOutput, + partial, + originalType: say, + toolData, + }) + }, + [addMessage], + ) + + /** + * Handle extension "ask" messages + */ + const handleAskMessage = useCallback( + (ts: number, ask: ClineAsk, text: string, partial: boolean) => { + const messageId = ts.toString() + + if (partial) { + return + } + + if (seenMessageIds.current.has(messageId)) { + return + } + + if (ask === "command_output") { + seenMessageIds.current.add(messageId) + return + } + + // Handle resume_task and resume_completed_task - stop loading and show text input + // Do not set pendingAsk - just stop loading so user sees normal input to type new message + if (ask === "resume_task" || ask === "resume_completed_task") { + seenMessageIds.current.add(messageId) + setLoading(false) + // Mark that a task has been started so subsequent messages continue the task + // (instead of starting a brand new task via runTask) + setHasStartedTask(true) + // Clear the resuming flag since we're now ready for interaction + // Historical messages should already be displayed from state processing + useCLIStore.getState().setIsResumingTask(false) + // Do not set pendingAsk - let the normal text input appear + return + } + + if (ask === "completion_result") { + seenMessageIds.current.add(messageId) + setComplete(true) + setLoading(false) + + // Parse the completion result and add a message for CompletionTool to render + try { + const completionInfo = JSON.parse(text) as Record + const toolData: ToolData = { + tool: "attempt_completion", + result: completionInfo.result as string | undefined, + content: completionInfo.result as string | undefined, + } + + addMessage({ + id: messageId, + role: "tool", + content: text, + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }), + originalType: ask, + toolData, + }) + } catch { + // If parsing fails, still add a basic completion message + addMessage({ + id: messageId, + role: "tool", + content: text || "Task completed", + toolName: "attempt_completion", + toolDisplayName: "Task Complete", + toolDisplayOutput: "✅ Task completed", + originalType: ask, + toolData: { + tool: "attempt_completion", + content: text, + }, + }) + } + return + } + + // Track pending command BEFORE nonInteractive handling + // This ensures we capture the command text for later injection into command_output toolData + if (ask === "command") { + pendingCommandRef.current = text + } + + if (nonInteractive && ask !== "followup") { + seenMessageIds.current.add(messageId) + + if (ask === "tool") { + let toolName: string | undefined + let toolDisplayName: string | undefined + let toolDisplayOutput: string | undefined + let formattedContent = text || "" + let toolData: ToolData | undefined + let todos: TodoItem[] | undefined + let previousTodos: TodoItem[] | undefined + + try { + const toolInfo = JSON.parse(text) as Record + toolName = toolInfo.tool as string + toolDisplayName = toolInfo.tool as string + toolDisplayOutput = formatToolOutput(toolInfo) + formattedContent = formatToolAskMessage(toolInfo) + // Extract structured toolData for rich rendering + toolData = extractToolData(toolInfo) + + // Special handling for update_todo_list tool - extract todos + if (toolName === "update_todo_list" || toolName === "updateTodoList") { + const parsedTodos = parseTodosFromToolInfo(toolInfo) + if (parsedTodos && parsedTodos.length > 0) { + todos = parsedTodos + // Capture previous todos before updating global state + previousTodos = [...currentTodos] + setTodos(parsedTodos) + } + } + } catch { + // Use raw text if not valid JSON + } + + addMessage({ + id: messageId, + role: "tool", + content: formattedContent, + toolName, + toolDisplayName, + toolDisplayOutput, + originalType: ask, + toolData, + todos, + previousTodos, + }) + } else { + addMessage({ + id: messageId, + role: "assistant", + content: text || "", + originalType: ask, + }) + } + return + } + + let suggestions: Array<{ answer: string; mode?: string | null }> | undefined + let questionText = text + + if (ask === "followup") { + try { + const data = JSON.parse(text) + questionText = data.question || text + suggestions = Array.isArray(data.suggest) ? data.suggest : undefined + } catch { + // Use raw text + } + } else if (ask === "tool") { + try { + const toolInfo = JSON.parse(text) as Record + questionText = formatToolAskMessage(toolInfo) + } catch { + // Use raw text if not valid JSON + } + } + // Note: ask === "command" is handled above before the nonInteractive block + + seenMessageIds.current.add(messageId) + + setPendingAsk({ + id: messageId, + type: ask, + content: questionText, + suggestions, + }) + }, + [addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos], + ) + + /** + * Handle all extension messages + */ + const handleExtensionMessage = useCallback( + (msg: ExtensionMessage) => { + if (msg.type === "state") { + const state = msg.state + + if (!state) { + return + } + + // Extract and update current mode from state + const newMode = state.mode + + if (newMode) { + setCurrentMode(newMode) + } + + // Extract and update task history from state + const newTaskHistory = state.taskHistory + + if (newTaskHistory && Array.isArray(newTaskHistory)) { + setTaskHistory(newTaskHistory) + } + + const clineMessages = state.clineMessages + + if (clineMessages) { + for (const clineMsg of clineMessages) { + const ts = clineMsg.ts + const type = clineMsg.type + const say = clineMsg.say + const ask = clineMsg.ask + const text = clineMsg.text || "" + const partial = clineMsg.partial || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } + + // Compute token usage metrics from clineMessages + // Skip first message (task prompt) as per webview UI pattern + if (clineMessages.length > 1) { + const processed = consolidateApiRequests( + consolidateCommands(clineMessages.slice(1) as ClineMessage[]), + ) + + const metrics = consolidateTokenUsage(processed) + setTokenUsage(metrics) + } + } + + // After processing state, clear the resuming flag if it was set + // This ensures the flag is cleared even if no resume_task ask message is received + if (useCLIStore.getState().isResumingTask) { + useCLIStore.getState().setIsResumingTask(false) + } + } else if (msg.type === "messageUpdated") { + const clineMessage = msg.clineMessage + + if (!clineMessage) { + return + } + + const ts = clineMessage.ts + const type = clineMessage.type + const say = clineMessage.say + const ask = clineMessage.ask + const text = clineMessage.text || "" + const partial = clineMessage.partial || false + + if (type === "say" && say) { + handleSayMessage(ts, say, text, partial) + } else if (type === "ask" && ask) { + handleAskMessage(ts, ask, text, partial) + } + } else if (msg.type === "fileSearchResults") { + setFileSearchResults((msg.results as FileResult[]) || []) + } else if (msg.type === "commands") { + setAllSlashCommands((msg.commands as SlashCommandResult[]) || []) + } else if (msg.type === "modes") { + setAvailableModes((msg.modes as ModeResult[]) || []) + } else if (msg.type === "routerModels") { + if (msg.routerModels) { + setRouterModels(msg.routerModels) + } + } + }, + [ + handleSayMessage, + handleAskMessage, + setFileSearchResults, + setAllSlashCommands, + setAvailableModes, + setCurrentMode, + setTokenUsage, + setRouterModels, + setTaskHistory, + ], + ) + + return { + handleExtensionMessage, + seenMessageIds, + pendingCommandRef, + firstTextMessageSkipped, + } +} diff --git a/apps/cli/src/ui/hooks/usePickerHandlers.ts b/apps/cli/src/ui/hooks/usePickerHandlers.ts new file mode 100644 index 00000000000..a27bdad3f0a --- /dev/null +++ b/apps/cli/src/ui/hooks/usePickerHandlers.ts @@ -0,0 +1,168 @@ +import { useCallback } from "react" +import type { WebviewMessage } from "@roo-code/types" + +import type { + AutocompletePickerState, + AutocompleteInputHandle, + ModeResult, + HistoryResult, +} from "../components/autocomplete/index.js" +import { useCLIStore } from "../store.js" +import { useUIStateStore } from "../stores/uiStateStore.js" + +export interface UsePickerHandlersOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + autocompleteRef: React.RefObject> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + followupAutocompleteRef: React.RefObject> + sendToExtension: ((msg: WebviewMessage) => void) | null + showInfo: (msg: string, duration?: number) => void + seenMessageIds: React.MutableRefObject> + firstTextMessageSkipped: React.MutableRefObject +} + +export interface UsePickerHandlersReturn { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlePickerStateChange: (state: AutocompletePickerState) => void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handlePickerSelect: (item: any) => void + handlePickerClose: () => void + handlePickerIndexChange: (index: number) => void +} + +/** + * Hook to handle autocomplete picker interactions. + * + * Responsibilities: + * - Handle picker state changes from AutocompleteInput + * - Handle item selection (special handling for modes and history items) + * - Handle mode switching via picker + * - Handle task switching via history picker + * - Handle picker close and index change + */ +export function usePickerHandlers({ + autocompleteRef, + followupAutocompleteRef, + sendToExtension, + showInfo, + seenMessageIds, + firstTextMessageSkipped, +}: UsePickerHandlersOptions): UsePickerHandlersReturn { + const { isLoading, currentTaskId, setCurrentTaskId } = useCLIStore() + const { pickerState, setPickerState } = useUIStateStore() + + /** + * Handle picker state changes from AutocompleteInput + */ + const handlePickerStateChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (state: AutocompletePickerState) => { + setPickerState(state) + }, + [setPickerState], + ) + + /** + * Handle item selection from external PickerSelect + */ + const handlePickerSelect = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any) => { + // Check if this is a mode selection. + if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) { + const modeItem = item as ModeResult + + if (sendToExtension) { + sendToExtension({ type: "mode", text: modeItem.slug }) + } + + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } + // Check if this is a history item selection. + else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) { + const historyItem = item as HistoryResult + + // Don't allow task switching while a task is in progress (loading). + if (isLoading) { + showInfo("Cannot switch tasks while task is in progress", 2000) + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + + // If selecting the same task that's already loaded, just close the picker. + if (historyItem.id === currentTaskId) { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + return + } + + // Send showTaskWithId message to extension to resume the task + if (sendToExtension) { + // Use selective reset that preserves global state (taskHistory, modes, commands) + useCLIStore.getState().resetForTaskSwitch() + // Set the resuming flag so message handlers know we're resuming + // This prevents skipping the first text message (which is historical) + useCLIStore.getState().setIsResumingTask(true) + // Track which task we're switching to + setCurrentTaskId(historyItem.id) + // Reset refs to avoid stale state across task switches + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + + // Send message to resume the selected task + // This triggers createTaskWithHistoryItem -> postStateToWebview + // which includes clineMessages and handles mode restoration + sendToExtension({ type: "showTaskWithId", text: historyItem.id }) + } + + // Close the picker + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + } else { + // Handle other item selections normally + autocompleteRef.current?.handleItemSelect(item) + followupAutocompleteRef.current?.handleItemSelect(item) + } + }, + [ + pickerState.activeTrigger, + isLoading, + showInfo, + currentTaskId, + setCurrentTaskId, + sendToExtension, + autocompleteRef, + followupAutocompleteRef, + seenMessageIds, + firstTextMessageSkipped, + ], + ) + + /** + * Handle picker close from external PickerSelect + */ + const handlePickerClose = useCallback(() => { + autocompleteRef.current?.closePicker() + followupAutocompleteRef.current?.closePicker() + }, [autocompleteRef, followupAutocompleteRef]) + + /** + * Handle picker index change from external PickerSelect + */ + const handlePickerIndexChange = useCallback( + (index: number) => { + autocompleteRef.current?.handleIndexChange(index) + followupAutocompleteRef.current?.handleIndexChange(index) + }, + [autocompleteRef, followupAutocompleteRef], + ) + + return { + handlePickerStateChange, + handlePickerSelect, + handlePickerClose, + handlePickerIndexChange, + } +} diff --git a/apps/cli/src/ui/hooks/useTaskSubmit.ts b/apps/cli/src/ui/hooks/useTaskSubmit.ts new file mode 100644 index 00000000000..c45d8bbd004 --- /dev/null +++ b/apps/cli/src/ui/hooks/useTaskSubmit.ts @@ -0,0 +1,182 @@ +import { useCallback } from "react" +import { randomUUID } from "crypto" +import type { WebviewMessage } from "@roo-code/types" + +import { getGlobalCommand } from "../../lib/utils/commands.js" + +import { useCLIStore } from "../store.js" +import { useUIStateStore } from "../stores/uiStateStore.js" + +export interface UseTaskSubmitOptions { + sendToExtension: ((msg: WebviewMessage) => void) | null + runTask: ((prompt: string) => Promise) | null + seenMessageIds: React.MutableRefObject> + firstTextMessageSkipped: React.MutableRefObject +} + +export interface UseTaskSubmitReturn { + handleSubmit: (text: string) => Promise + handleApprove: () => void + handleReject: () => void +} + +/** + * Hook to handle task submission, user responses, and approvals. + * + * Responsibilities: + * - Process user message submissions + * - Detect and handle global commands (like /new) + * - Handle pending ask responses + * - Start new tasks or continue existing ones + * - Handle Y/N approval responses + */ +export function useTaskSubmit({ + sendToExtension, + runTask, + seenMessageIds, + firstTextMessageSkipped, +}: UseTaskSubmitOptions): UseTaskSubmitReturn { + const { + pendingAsk, + hasStartedTask, + isComplete, + addMessage, + setPendingAsk, + setHasStartedTask, + setLoading, + setComplete, + setError, + } = useCLIStore() + + const { setShowCustomInput, setIsTransitioningToCustomInput } = useUIStateStore() + + /** + * Handle user text submission (from input or followup question) + */ + const handleSubmit = useCallback( + async (text: string) => { + if (!sendToExtension || !text.trim()) { + return + } + + const trimmedText = text.trim() + + if (trimmedText === "__CUSTOM__") { + return + } + + // Check for CLI global action commands (e.g., /new) + if (trimmedText.startsWith("/")) { + const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/) + + if (commandMatch && commandMatch[1]) { + const globalCommand = getGlobalCommand(commandMatch[1]) + + if (globalCommand?.action === "clearTask") { + // Reset CLI state and send clearTask to extension + useCLIStore.getState().reset() + // Reset component-level refs to avoid stale message tracking + seenMessageIds.current.clear() + firstTextMessageSkipped.current = false + sendToExtension({ type: "clearTask" }) + // Re-request state, commands and modes since reset() cleared them + sendToExtension({ type: "webviewDidLaunch" }) + sendToExtension({ type: "requestCommands" }) + sendToExtension({ type: "requestModes" }) + return + } + } + } + + if (pendingAsk) { + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + + setPendingAsk(null) + setShowCustomInput(false) + setIsTransitioningToCustomInput(false) + setLoading(true) + } else if (!hasStartedTask) { + setHasStartedTask(true) + setLoading(true) + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + try { + if (runTask) { + await runTask(trimmedText) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) + } + } else { + if (isComplete) { + setComplete(false) + } + + setLoading(true) + addMessage({ id: randomUUID(), role: "user", content: trimmedText }) + + sendToExtension({ + type: "askResponse", + askResponse: "messageResponse", + text: trimmedText, + }) + } + }, + [ + sendToExtension, + runTask, + pendingAsk, + hasStartedTask, + isComplete, + addMessage, + setPendingAsk, + setHasStartedTask, + setLoading, + setComplete, + setError, + setShowCustomInput, + setIsTransitioningToCustomInput, + seenMessageIds, + firstTextMessageSkipped, + ], + ) + + /** + * Handle approval (Y key) + */ + const handleApprove = useCallback(() => { + if (!sendToExtension) { + return + } + + sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" }) + setPendingAsk(null) + setLoading(true) + }, [sendToExtension, setPendingAsk, setLoading]) + + /** + * Handle rejection (N key) + */ + const handleReject = useCallback(() => { + if (!sendToExtension) { + return + } + + sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" }) + setPendingAsk(null) + setLoading(true) + }, [sendToExtension, setPendingAsk, setLoading]) + + return { + handleSubmit, + handleApprove, + handleReject, + } +} diff --git a/apps/cli/src/ui/hooks/useTerminalSize.ts b/apps/cli/src/ui/hooks/useTerminalSize.ts new file mode 100644 index 00000000000..01ea262f6af --- /dev/null +++ b/apps/cli/src/ui/hooks/useTerminalSize.ts @@ -0,0 +1,59 @@ +/** + * useTerminalSize - Hook that tracks terminal dimensions and re-renders on resize + * Includes debouncing to prevent rendering issues during rapid resizing + */ + +import { useState, useEffect, useRef } from "react" + +interface TerminalSize { + columns: number + rows: number +} + +/** + * Returns the current terminal size and re-renders when it changes + * Debounces resize events to prevent rendering artifacts + */ +export function useTerminalSize(): TerminalSize { + // Get initial size synchronously - this is the value used for first render + const [size, setSize] = useState(() => ({ + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + })) + + const debounceTimer = useRef(null) + + useEffect(() => { + const handleResize = () => { + // Clear any pending debounce + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + + // Debounce resize events by 50ms + debounceTimer.current = setTimeout(() => { + // Clear the terminal before updating size to prevent artifacts + process.stdout.write("\x1b[2J\x1b[H") + + setSize({ + columns: process.stdout.columns || 80, + rows: process.stdout.rows || 24, + }) + debounceTimer.current = null + }, 50) + } + + // Listen for resize events + process.stdout.on("resize", handleResize) + + // Cleanup + return () => { + process.stdout.off("resize", handleResize) + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + } + }, []) + + return size +} diff --git a/apps/cli/src/ui/hooks/useToast.ts b/apps/cli/src/ui/hooks/useToast.ts new file mode 100644 index 00000000000..18d5bcda07d --- /dev/null +++ b/apps/cli/src/ui/hooks/useToast.ts @@ -0,0 +1,196 @@ +import { create } from "zustand" +import { useEffect, useCallback, useRef } from "react" + +/** + * Toast message types for different visual styles + */ +export type ToastType = "info" | "success" | "warning" | "error" + +/** + * A single toast message in the queue + */ +export interface Toast { + id: string + message: string + type: ToastType + /** Duration in milliseconds before auto-dismiss (default: 3000) */ + duration: number + /** Timestamp when the toast was created */ + createdAt: number +} + +/** + * Toast queue store state + */ +interface ToastState { + /** Queue of active toasts (FIFO - first one is displayed) */ + toasts: Toast[] + /** Add a toast to the queue */ + addToast: (message: string, type?: ToastType, duration?: number) => string + /** Remove a specific toast by ID */ + removeToast: (id: string) => void + /** Clear all toasts */ + clearToasts: () => void +} + +/** + * Default toast duration in milliseconds + */ +const DEFAULT_DURATION = 3000 + +/** + * Generate a unique ID for toasts + */ +let toastIdCounter = 0 +function generateToastId(): string { + return `toast-${Date.now()}-${++toastIdCounter}` +} + +/** + * Zustand store for toast queue management + */ +export const useToastStore = create((set) => ({ + toasts: [], + + addToast: (message: string, type: ToastType = "info", duration: number = DEFAULT_DURATION) => { + const id = generateToastId() + const toast: Toast = { + id, + message, + type, + duration, + createdAt: Date.now(), + } + + // Replace any existing toasts - new toast shows immediately + // This provides better UX as users see the most recent message right away + set(() => ({ + toasts: [toast], + })) + + return id + }, + + removeToast: (id: string) => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })) + }, + + clearToasts: () => { + set({ toasts: [] }) + }, +})) + +/** + * Hook for displaying and managing toasts with auto-expiry. + * Returns the current toast (if any) and utility functions. + * + * The hook handles auto-dismissal of toasts after their duration expires. + */ +export function useToast() { + const { toasts, addToast, removeToast, clearToasts } = useToastStore() + + // Track active timers for cleanup + const timersRef = useRef>(new Map()) + + // Get the current toast to display (first in queue) + const currentToast = toasts.length > 0 ? toasts[0] : null + + // Set up auto-dismissal timer for current toast + useEffect(() => { + if (!currentToast) { + return + } + + // Check if timer already exists for this toast + if (timersRef.current.has(currentToast.id)) { + return + } + + // Calculate remaining time (accounts for time already elapsed) + const elapsed = Date.now() - currentToast.createdAt + const remainingTime = Math.max(0, currentToast.duration - elapsed) + + const timer = setTimeout(() => { + removeToast(currentToast.id) + timersRef.current.delete(currentToast.id) + }, remainingTime) + + timersRef.current.set(currentToast.id, timer) + + return () => { + // Clean up timer if toast is removed before expiry + const existingTimer = timersRef.current.get(currentToast.id) + if (existingTimer) { + clearTimeout(existingTimer) + timersRef.current.delete(currentToast.id) + } + } + }, [currentToast?.id, currentToast?.createdAt, currentToast?.duration, removeToast]) + + // Cleanup all timers on unmount + useEffect(() => { + return () => { + timersRef.current.forEach((timer) => clearTimeout(timer)) + timersRef.current.clear() + } + }, []) + + // Convenience methods for different toast types + const showToast = useCallback( + (message: string, type?: ToastType, duration?: number) => { + return addToast(message, type, duration) + }, + [addToast], + ) + + const showInfo = useCallback( + (message: string, duration?: number) => { + return addToast(message, "info", duration) + }, + [addToast], + ) + + const showSuccess = useCallback( + (message: string, duration?: number) => { + return addToast(message, "success", duration) + }, + [addToast], + ) + + const showWarning = useCallback( + (message: string, duration?: number) => { + return addToast(message, "warning", duration) + }, + [addToast], + ) + + const showError = useCallback( + (message: string, duration?: number) => { + return addToast(message, "error", duration) + }, + [addToast], + ) + + return { + /** Current toast being displayed (first in queue) */ + currentToast, + /** All toasts in the queue */ + toasts, + /** Generic toast display method */ + showToast, + /** Show an info toast */ + showInfo, + /** Show a success toast */ + showSuccess, + /** Show a warning toast */ + showWarning, + /** Show an error toast */ + showError, + /** Remove a specific toast by ID */ + removeToast, + /** Clear all toasts */ + clearToasts, + } +} diff --git a/apps/cli/src/ui/store.ts b/apps/cli/src/ui/store.ts new file mode 100644 index 00000000000..6c9566a0067 --- /dev/null +++ b/apps/cli/src/ui/store.ts @@ -0,0 +1,295 @@ +import { create } from "zustand" + +import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types" + +import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js" +import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js" + +/** + * Shallow array equality check - compares array length and element references. + * Used to prevent unnecessary state updates when array content hasn't changed. + */ +function shallowArrayEqual(a: T[], b: T[]): boolean { + if (a === b) return true + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +/** + * Streaming message debounce configuration. + * Batches rapid partial message updates to reduce re-renders during streaming. + * Higher values = fewer renders but text appears more "chunky" + * Lower values = smoother text but more renders + */ +const STREAMING_DEBOUNCE_MS = 150 // 150ms debounce for aggressive batching + +// Pending streaming updates - batched and flushed after debounce interval +interface PendingStreamUpdate { + id: string + content: string + partial: boolean + timestamp: number +} + +const pendingStreamUpdates: Map = new Map() +let streamingDebounceTimer: ReturnType | null = null + +/** + * RouterModels type for context window lookup. + * Simplified version - we only need contextWindow from ModelInfo. + */ +export type RouterModels = Record> + +/** + * CLI application state. + * + * Note: Autocomplete picker UI state (isOpen, selectedIndex) is now managed + * by the useAutocompletePicker hook. The store only holds data that needs + * to be shared between components or persisted (like search results from API). + */ +interface CLIState { + // Message history + messages: TUIMessage[] + pendingAsk: PendingAsk | null + + // Task state + isLoading: boolean + isComplete: boolean + hasStartedTask: boolean + error: string | null + + // Task resumption flag - true when resuming a task from history + // Used to modify message processing behavior (e.g., don't skip first text message) + isResumingTask: boolean + + // Autocomplete data (from API/extension) + fileSearchResults: FileResult[] + allSlashCommands: SlashCommandResult[] + availableModes: ModeResult[] + + // Task history (for resuming previous tasks) + taskHistory: TaskHistoryItem[] + + // Current task ID (for detecting same-task reselection) + currentTaskId: string | null + + // Current mode (updated reactively when mode changes) + currentMode: string | null + + // Token usage metrics (from getApiMetrics) + tokenUsage: TokenUsage | null + + // Model info for context window lookup + routerModels: RouterModels | null + apiConfiguration: ProviderSettings | null + + // Todo list tracking + currentTodos: TodoItem[] + previousTodos: TodoItem[] +} + +interface CLIActions { + // Message actions + addMessage: (msg: TUIMessage) => void + updateMessage: (id: string, content: string, partial?: boolean) => void + + // Task actions + setPendingAsk: (ask: PendingAsk | null) => void + setLoading: (loading: boolean) => void + setComplete: (complete: boolean) => void + setHasStartedTask: (started: boolean) => void + setError: (error: string | null) => void + reset: () => void + /** Reset for task switching - preserves global state (taskHistory, modes, commands) */ + resetForTaskSwitch: () => void + /** Set the isResumingTask flag - used when resuming a task from history */ + setIsResumingTask: (isResuming: boolean) => void + + // Autocomplete data actions + setFileSearchResults: (results: FileResult[]) => void + setAllSlashCommands: (commands: SlashCommandResult[]) => void + setAvailableModes: (modes: ModeResult[]) => void + + // Task history action + setTaskHistory: (history: TaskHistoryItem[]) => void + + // Current task ID action + setCurrentTaskId: (taskId: string | null) => void + + // Current mode action + setCurrentMode: (mode: string | null) => void + + // Metrics actions + setTokenUsage: (usage: TokenUsage | null) => void + setRouterModels: (models: RouterModels | null) => void + setApiConfiguration: (config: ProviderSettings | null) => void + + // Todo actions + setTodos: (todos: TodoItem[]) => void +} + +const initialState: CLIState = { + messages: [], + pendingAsk: null, + isLoading: false, + isComplete: false, + hasStartedTask: false, + error: null, + isResumingTask: false, + fileSearchResults: [], + allSlashCommands: [], + availableModes: [], + taskHistory: [], + currentTaskId: null, + currentMode: null, + tokenUsage: null, + routerModels: null, + apiConfiguration: null, + currentTodos: [], + previousTodos: [], +} + +export const useCLIStore = create((set, get) => ({ + ...initialState, + + addMessage: (msg) => { + const state = get() + // Check if message already exists (by ID). + const existingIndex = state.messages.findIndex((m) => m.id === msg.id) + + // For NEW messages (not updates) - always apply immediately + if (existingIndex === -1) { + set({ messages: [...state.messages, msg] }) + return + } + + // For UPDATES to existing messages: + // If partial (streaming) and message exists, debounce the update + if (msg.partial) { + // Queue the update + pendingStreamUpdates.set(msg.id, { + id: msg.id, + content: msg.content, + partial: true, + timestamp: Date.now(), + }) + + // Schedule flush if not already scheduled + if (!streamingDebounceTimer) { + streamingDebounceTimer = setTimeout(() => { + // Flush all pending updates as a single batch + const currentState = get() + const updates = Array.from(pendingStreamUpdates.values()) + pendingStreamUpdates.clear() + streamingDebounceTimer = null + + if (updates.length === 0) return + + // Apply all pending updates in one state change + const newMessages = [...currentState.messages] + let hasChanges = false + + for (const update of updates) { + const idx = newMessages.findIndex((m) => m.id === update.id) + if (idx !== -1 && newMessages[idx]) { + newMessages[idx] = { + ...newMessages[idx], + content: update.content, + partial: update.partial, + } + hasChanges = true + } + } + + if (hasChanges) { + set({ messages: newMessages }) + } + }, STREAMING_DEBOUNCE_MS) + } + return + } + + // Non-partial update (final message) - apply immediately and clear any pending + // This ensures the final complete message is always shown + pendingStreamUpdates.delete(msg.id) + + const updated = [...state.messages] + updated[existingIndex] = msg + set({ messages: updated }) + }, + + updateMessage: (id, content, partial) => + set((state) => { + const index = state.messages.findIndex((m) => m.id === id) + + if (index === -1) { + return state + } + + const existing = state.messages[index] + + if (!existing) { + return state + } + + const updated = [...state.messages] + + updated[index] = { + ...existing, + content, + partial: partial !== undefined ? partial : existing.partial, + } + + return { messages: updated } + }), + + setPendingAsk: (ask) => set({ pendingAsk: ask }), + setLoading: (loading) => set({ isLoading: loading }), + setComplete: (complete) => set({ isComplete: complete }), + setHasStartedTask: (started) => set({ hasStartedTask: started }), + setError: (error) => set({ error }), + reset: () => set(initialState), + resetForTaskSwitch: () => + set((state) => ({ + // Clear task-specific state + messages: [], + pendingAsk: null, + isLoading: false, + isComplete: false, + hasStartedTask: false, + error: null, + isResumingTask: false, + tokenUsage: null, + currentTodos: [], + previousTodos: [], + // currentTaskId is preserved - will be updated to new task ID by caller + currentTaskId: state.currentTaskId, + // PRESERVE global state - don't clear these + taskHistory: state.taskHistory, + availableModes: state.availableModes, + allSlashCommands: state.allSlashCommands, + fileSearchResults: state.fileSearchResults, + currentMode: state.currentMode, + routerModels: state.routerModels, + apiConfiguration: state.apiConfiguration, + })), + setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }), + // Use shallow equality to prevent unnecessary re-renders when array content is the same + setFileSearchResults: (results) => + set((state) => (shallowArrayEqual(state.fileSearchResults, results) ? state : { fileSearchResults: results })), + setAllSlashCommands: (commands) => + set((state) => (shallowArrayEqual(state.allSlashCommands, commands) ? state : { allSlashCommands: commands })), + setAvailableModes: (modes) => + set((state) => (shallowArrayEqual(state.availableModes, modes) ? state : { availableModes: modes })), + setTaskHistory: (history) => + set((state) => (shallowArrayEqual(state.taskHistory, history) ? state : { taskHistory: history })), + setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }), + setCurrentMode: (mode) => set({ currentMode: mode }), + setTokenUsage: (usage) => set({ tokenUsage: usage }), + setRouterModels: (models) => set({ routerModels: models }), + setApiConfiguration: (config) => set({ apiConfiguration: config }), + setTodos: (todos) => set((state) => ({ previousTodos: state.currentTodos, currentTodos: todos })), +})) diff --git a/apps/cli/src/ui/stores/uiStateStore.ts b/apps/cli/src/ui/stores/uiStateStore.ts new file mode 100644 index 00000000000..d7abe598451 --- /dev/null +++ b/apps/cli/src/ui/stores/uiStateStore.ts @@ -0,0 +1,87 @@ +import { create } from "zustand" +import type { AutocompletePickerState } from "../components/autocomplete/types.js" + +/** + * UI-specific state that doesn't need to persist across task switches. + * This separates UI state from task/message state in the main CLI store. + */ +interface UIState { + // Exit handling state + showExitHint: boolean + pendingExit: boolean + + // Countdown timer for auto-accepting followup questions + countdownSeconds: number | null + + // Custom input mode for followup questions + showCustomInput: boolean + isTransitioningToCustomInput: boolean + + // Focus management for scroll area vs input + manualFocus: "scroll" | "input" | null + + // TODO viewer overlay + showTodoViewer: boolean + + // Autocomplete picker state + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pickerState: AutocompletePickerState +} + +interface UIActions { + // Exit handling actions + setShowExitHint: (show: boolean) => void + setPendingExit: (pending: boolean) => void + + // Countdown timer actions + setCountdownSeconds: (seconds: number | null) => void + + // Custom input mode actions + setShowCustomInput: (show: boolean) => void + setIsTransitioningToCustomInput: (transitioning: boolean) => void + + // Focus management actions + setManualFocus: (focus: "scroll" | "input" | null) => void + + // TODO viewer actions + setShowTodoViewer: (show: boolean) => void + + // Picker state actions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setPickerState: (state: AutocompletePickerState) => void + + // Reset all UI state to defaults + resetUIState: () => void +} + +const initialState: UIState = { + showExitHint: false, + pendingExit: false, + countdownSeconds: null, + showCustomInput: false, + isTransitioningToCustomInput: false, + manualFocus: null, + showTodoViewer: false, + pickerState: { + activeTrigger: null, + results: [], + selectedIndex: 0, + isOpen: false, + isLoading: false, + triggerInfo: null, + }, +} + +export const useUIStateStore = create((set) => ({ + ...initialState, + + setShowExitHint: (show) => set({ showExitHint: show }), + setPendingExit: (pending) => set({ pendingExit: pending }), + setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }), + setShowCustomInput: (show) => set({ showCustomInput: show }), + setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }), + setManualFocus: (focus) => set({ manualFocus: focus }), + setShowTodoViewer: (show) => set({ showTodoViewer: show }), + setPickerState: (state) => set({ pickerState: state }), + resetUIState: () => set(initialState), +})) diff --git a/apps/cli/src/ui/theme.ts b/apps/cli/src/ui/theme.ts new file mode 100644 index 00000000000..bce18f76290 --- /dev/null +++ b/apps/cli/src/ui/theme.ts @@ -0,0 +1,79 @@ +/** + * Theme configuration for Roo Code CLI TUI + * Using Hardcore color scheme + */ + +// Hardcore palette +const hardcore = { + // Accent colors + pink: "#F92672", + pinkLight: "#FF669D", + green: "#A6E22E", + greenLight: "#BEED5F", + orange: "#FD971F", + yellow: "#E6DB74", + cyan: "#66D9EF", + purple: "#9E6FFE", + + // Text colors + text: "#F8F8F2", + subtext1: "#CCCCC6", + subtext0: "#A3BABF", + + // Overlay colors + overlay2: "#A3BABF", + overlay1: "#5E7175", + overlay0: "#505354", + + // Surface colors + surface2: "#505354", + surface1: "#383a3e", + surface0: "#2d2e2e", + + // Base colors + base: "#1B1D1E", + mantle: "#161819", + crust: "#101112", +} + +// Title and branding colors +export const titleColor = hardcore.orange // Orange for title +export const welcomeText = hardcore.text // Standard text +export const asciiColor = hardcore.cyan // Cyan for ASCII art + +// Tips section colors +export const tipsHeader = hardcore.orange // Orange for tips headers +export const tipsText = hardcore.subtext0 // Subtle text for tips + +// Header text colors (for messages) +export const userHeader = hardcore.purple // Purple for user header +export const rooHeader = hardcore.yellow // Yellow for roo +export const toolHeader = hardcore.cyan // Cyan for tool headers +export const thinkingHeader = hardcore.overlay1 // Subtle gray for thinking header + +// Message text colors +export const userText = hardcore.text // Standard text for user +export const rooText = hardcore.text // Standard text for roo +export const toolText = hardcore.subtext0 // Subtle text for tool output +export const thinkingText = hardcore.overlay2 // Subtle gray for thinking text + +// UI element colors +export const borderColor = hardcore.surface1 // Surface color for borders +export const borderColorActive = hardcore.purple // Active/focused border color +export const dimText = hardcore.overlay1 // Dim text +export const promptColor = hardcore.overlay2 // Prompt indicator +export const promptColorActive = hardcore.cyan // Active prompt color +export const placeholderColor = hardcore.overlay0 // Placeholder text + +// Status colors +export const successColor = hardcore.green // Green for success +export const errorColor = hardcore.pink // Pink for errors +export const warningColor = hardcore.yellow // Yellow for warnings + +// Focus indicator colors +export const focusColor = hardcore.cyan // Focus indicator (cyan accent) +export const scrollActiveColor = hardcore.purple // Scroll area active indicator (purple) +export const scrollTrackColor = hardcore.surface1 // Muted scrollbar track color + +// Base text color +export const text = hardcore.text // Standard text color diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts new file mode 100644 index 00000000000..c2187fb2b66 --- /dev/null +++ b/apps/cli/src/ui/types.ts @@ -0,0 +1,123 @@ +import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types" + +export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking" + +export interface ToolData { + /** Tool identifier (e.g., "readFile", "appliedDiff", "searchFiles") */ + tool: string + + // File operation fields + /** File path */ + path?: string + /** Whether the file is outside the workspace */ + isOutsideWorkspace?: boolean + /** Whether the file is write-protected */ + isProtected?: boolean + /** Unified diff content */ + diff?: string + /** Diff statistics */ + diffStats?: { added: number; removed: number } + /** General content (file content, search results, etc.) */ + content?: string + + // Search operation fields + /** Search regex pattern */ + regex?: string + /** File pattern filter */ + filePattern?: string + /** Search query (for codebase search) */ + query?: string + + // Mode operation fields + /** Target mode slug */ + mode?: string + /** Reason for mode switch or other actions */ + reason?: string + + // Command operation fields + /** Command string */ + command?: string + /** Command output */ + output?: string + + // Browser operation fields + /** Browser action type */ + action?: string + /** Browser URL */ + url?: string + /** Click/hover coordinates */ + coordinate?: string + + // Batch operation fields + /** Batch file reads */ + batchFiles?: Array<{ + path: string + lineSnippet?: string + isOutsideWorkspace?: boolean + key?: string + content?: string + }> + /** Batch diff operations */ + batchDiffs?: Array<{ + path: string + changeCount?: number + key?: string + content?: string + diffStats?: { added: number; removed: number } + diffs?: Array<{ + content: string + startLine?: number + }> + }> + + // Question/completion fields + /** Question text for ask_followup_question */ + question?: string + /** Result text for attempt_completion */ + result?: string + + // Additional display hints + /** Line number for context */ + lineNumber?: number + /** Additional file count for batch operations */ + additionalFileCount?: number +} + +export interface TUIMessage { + id: string + role: MessageRole + content: string + toolName?: string + toolDisplayName?: string + toolDisplayOutput?: string + hasPendingToolCalls?: boolean + partial?: boolean + originalType?: ClineAsk | ClineSay + /** TODO items for update_todo_list tool messages */ + todos?: TodoItem[] + /** Previous TODO items for diff display */ + previousTodos?: TodoItem[] + /** Structured tool data for rich rendering */ + toolData?: ToolData +} + +export interface PendingAsk { + id: string + type: ClineAsk + content: string + suggestions?: Array<{ answer: string; mode?: string | null }> +} + +export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default" + +export interface TaskHistoryItem { + id: string + task: string + ts: number + totalCost?: number + workspace?: string + mode?: string + status?: "active" | "completed" | "delegated" + tokensIn?: number + tokensOut?: number +} diff --git a/apps/cli/src/ui/utils/index.ts b/apps/cli/src/ui/utils/index.ts new file mode 100644 index 00000000000..9c55726d641 --- /dev/null +++ b/apps/cli/src/ui/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./tools.js" +export * from "./views.js" diff --git a/apps/cli/src/ui/utils/tools.ts b/apps/cli/src/ui/utils/tools.ts new file mode 100644 index 00000000000..be3ff9484db --- /dev/null +++ b/apps/cli/src/ui/utils/tools.ts @@ -0,0 +1,346 @@ +import type { TodoItem } from "@roo-code/types" + +import type { ToolData } from "../types.js" + +/** + * Extract structured ToolData from parsed tool JSON + * This provides rich data for tool-specific renderers + */ +export function extractToolData(toolInfo: Record): ToolData { + const toolName = (toolInfo.tool as string) || "unknown" + + // Base tool data with common fields + const toolData: ToolData = { + tool: toolName, + path: toolInfo.path as string | undefined, + isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined, + isProtected: toolInfo.isProtected as boolean | undefined, + content: toolInfo.content as string | undefined, + reason: toolInfo.reason as string | undefined, + } + + // Extract diff-related fields + if (toolInfo.diff !== undefined) { + toolData.diff = toolInfo.diff as string + } + if (toolInfo.diffStats !== undefined) { + const stats = toolInfo.diffStats as { added?: number; removed?: number } + if (typeof stats.added === "number" && typeof stats.removed === "number") { + toolData.diffStats = { added: stats.added, removed: stats.removed } + } + } + + // Extract search-related fields + if (toolInfo.regex !== undefined) { + toolData.regex = toolInfo.regex as string + } + if (toolInfo.filePattern !== undefined) { + toolData.filePattern = toolInfo.filePattern as string + } + if (toolInfo.query !== undefined) { + toolData.query = toolInfo.query as string + } + + // Extract mode-related fields + if (toolInfo.mode !== undefined) { + toolData.mode = toolInfo.mode as string + } + if (toolInfo.mode_slug !== undefined) { + toolData.mode = toolInfo.mode_slug as string + } + + // Extract command-related fields + if (toolInfo.command !== undefined) { + toolData.command = toolInfo.command as string + } + if (toolInfo.output !== undefined) { + toolData.output = toolInfo.output as string + } + + // Extract browser-related fields + if (toolInfo.action !== undefined) { + toolData.action = toolInfo.action as string + } + if (toolInfo.url !== undefined) { + toolData.url = toolInfo.url as string + } + if (toolInfo.coordinate !== undefined) { + toolData.coordinate = toolInfo.coordinate as string + } + + // Extract batch file operations + if (Array.isArray(toolInfo.files)) { + toolData.batchFiles = (toolInfo.files as Array>).map((f) => ({ + path: (f.path as string) || "", + lineSnippet: f.lineSnippet as string | undefined, + isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined, + key: f.key as string | undefined, + content: f.content as string | undefined, + })) + } + + // Extract batch diff operations + if (Array.isArray(toolInfo.batchDiffs)) { + toolData.batchDiffs = (toolInfo.batchDiffs as Array>).map((d) => ({ + path: (d.path as string) || "", + changeCount: d.changeCount as number | undefined, + key: d.key as string | undefined, + content: d.content as string | undefined, + diffStats: d.diffStats as { added: number; removed: number } | undefined, + diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined, + })) + } + + // Extract question/completion fields + if (toolInfo.question !== undefined) { + toolData.question = toolInfo.question as string + } + if (toolInfo.result !== undefined) { + toolData.result = toolInfo.result as string + } + + // Extract additional display hints + if (toolInfo.lineNumber !== undefined) { + toolData.lineNumber = toolInfo.lineNumber as number + } + if (toolInfo.additionalFileCount !== undefined) { + toolData.additionalFileCount = toolInfo.additionalFileCount as number + } + + return toolData +} + +/** + * Format tool output for display (used in the message body, header shows tool name separately) + */ +export function formatToolOutput(toolInfo: Record): string { + const toolName = (toolInfo.tool as string) || "unknown" + + switch (toolName) { + case "switchMode": { + const mode = (toolInfo.mode as string) || "unknown" + const reason = toolInfo.reason as string + return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` + } + + case "switch_mode": { + const mode = (toolInfo.mode_slug as string) || (toolInfo.mode as string) || "unknown" + const reason = toolInfo.reason as string + return `→ ${mode} mode${reason ? `\n ${reason}` : ""}` + } + + case "execute_command": { + const command = toolInfo.command as string + return `$ ${command || "(no command)"}` + } + + case "read_file": { + const files = toolInfo.files as Array<{ path: string }> | undefined + const path = toolInfo.path as string + if (files && files.length > 0) { + return files.map((f) => `📄 ${f.path}`).join("\n") + } + return `📄 ${path || "(no path)"}` + } + + case "write_to_file": { + const writePath = toolInfo.path as string + return `📝 ${writePath || "(no path)"}` + } + + case "apply_diff": { + const diffPath = toolInfo.path as string + return `✏️ ${diffPath || "(no path)"}` + } + + case "search_files": { + const searchPath = toolInfo.path as string + const regex = toolInfo.regex as string + return `🔍 "${regex}" in ${searchPath || "."}` + } + + case "list_files": { + const listPath = toolInfo.path as string + const recursive = toolInfo.recursive as boolean + return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}` + } + + case "browser_action": { + const action = toolInfo.action as string + const url = toolInfo.url as string + return `🌐 ${action || "action"}${url ? `: ${url}` : ""}` + } + + case "attempt_completion": { + const result = toolInfo.result as string + if (result) { + const truncated = result.length > 100 ? result.substring(0, 100) + "..." : result + return `✅ ${truncated}` + } + return "✅ Task completed" + } + + case "ask_followup_question": { + const question = toolInfo.question as string + return `❓ ${question || "(no question)"}` + } + + case "new_task": { + const taskMode = toolInfo.mode as string + return `📋 Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}` + } + + case "update_todo_list": + case "updateTodoList": { + // Special marker - actual rendering is handled by TodoChangeDisplay component + return "☑ TODO list updated" + } + + default: { + const params = Object.entries(toolInfo) + .filter(([key]) => key !== "tool") + .map(([key, value]) => { + const displayValue = typeof value === "string" ? value : JSON.stringify(value) + const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue + return `${key}: ${truncated}` + }) + .join("\n") + return params || "(no parameters)" + } + } +} + +/** + * Format tool ask message for user approval prompt + */ +export function formatToolAskMessage(toolInfo: Record): string { + const toolName = (toolInfo.tool as string) || "unknown" + + switch (toolName) { + case "switchMode": + case "switch_mode": { + const mode = (toolInfo.mode as string) || (toolInfo.mode_slug as string) || "unknown" + const reason = toolInfo.reason as string + return `Switch to ${mode} mode?${reason ? `\nReason: ${reason}` : ""}` + } + + case "execute_command": { + const command = toolInfo.command as string + return `Run command?\n$ ${command || "(no command)"}` + } + + case "read_file": { + const files = toolInfo.files as Array<{ path: string }> | undefined + const path = toolInfo.path as string + if (files && files.length > 0) { + return `Read ${files.length} file(s)?\n${files.map((f) => ` ${f.path}`).join("\n")}` + } + return `Read file: ${path || "(no path)"}` + } + + case "write_to_file": { + const writePath = toolInfo.path as string + return `Write to file: ${writePath || "(no path)"}` + } + + case "apply_diff": { + const diffPath = toolInfo.path as string + return `Apply changes to: ${diffPath || "(no path)"}` + } + + case "browser_action": { + const action = toolInfo.action as string + const url = toolInfo.url as string + return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}` + } + + default: { + const params = Object.entries(toolInfo) + .filter(([key]) => key !== "tool") + .map(([key, value]) => { + const displayValue = typeof value === "string" ? value : JSON.stringify(value) + const truncated = displayValue.length > 80 ? displayValue.substring(0, 80) + "..." : displayValue + return ` ${key}: ${truncated}` + }) + .join("\n") + return `${toolName}${params ? `\n${params}` : ""}` + } + } +} + +/** + * Parse TODO items from tool info + * Handles both array format and markdown checklist string format + */ +export function parseTodosFromToolInfo(toolInfo: Record): TodoItem[] | null { + // Try to get todos directly as an array + const todosArray = toolInfo.todos as unknown[] | undefined + if (Array.isArray(todosArray)) { + return todosArray + .map((item, index) => { + if (typeof item === "object" && item !== null) { + const todo = item as Record + return { + id: (todo.id as string) || `todo-${index}`, + content: (todo.content as string) || "", + status: ((todo.status as string) || "pending") as TodoItem["status"], + } + } + return null + }) + .filter((item): item is TodoItem => item !== null) + } + + // Try to parse markdown checklist format from todos string + const todosString = toolInfo.todos as string | undefined + if (typeof todosString === "string") { + return parseMarkdownChecklist(todosString) + } + + return null +} + +/** + * Parse a markdown checklist string into TodoItem array + * Format: + * [ ] pending item + * [-] in progress item + * [x] completed item + */ +export function parseMarkdownChecklist(markdown: string): TodoItem[] { + const lines = markdown.split("\n") + const todos: TodoItem[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + if (!line) { + continue + } + + const trimmedLine = line.trim() + + if (!trimmedLine) { + continue + } + + // Match markdown checkbox patterns + const checkboxMatch = trimmedLine.match(/^\[([x\-\s])\]\s*(.+)$/i) + + if (checkboxMatch) { + const statusChar = checkboxMatch[1] ?? " " + const content = checkboxMatch[2] ?? "" + let status: TodoItem["status"] = "pending" + + if (statusChar.toLowerCase() === "x") { + status = "completed" + } else if (statusChar === "-") { + status = "in_progress" + } + + todos.push({ id: `todo-${i}`, content: content.trim(), status }) + } + } + + return todos +} diff --git a/apps/cli/src/ui/utils/views.ts b/apps/cli/src/ui/utils/views.ts new file mode 100644 index 00000000000..0e2ea07c8b4 --- /dev/null +++ b/apps/cli/src/ui/utils/views.ts @@ -0,0 +1,52 @@ +import type { TUIMessage, PendingAsk, View } from "../types.js" + +/** + * Determine the current view state based on messages and pending asks + */ +export function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View { + // If there's a pending ask requiring text input, show input + if (pendingAsk?.type === "followup") { + return "UserInput" + } + + // If there's any pending ask (approval), don't show thinking + if (pendingAsk) { + return "UserInput" + } + + // Initial state or empty - awaiting user input + if (messages.length === 0) { + return "UserInput" + } + + const lastMessage = messages.at(-1) + if (!lastMessage) { + return "UserInput" + } + + // User just sent a message, waiting for response + if (lastMessage.role === "user") { + return "AgentResponse" + } + + // Assistant replied + if (lastMessage.role === "assistant") { + if (lastMessage.hasPendingToolCalls) { + return "ToolUse" + } + + // If loading, still waiting for more + if (isLoading) { + return "AgentResponse" + } + + return "UserInput" + } + + // Tool result received, waiting for next assistant response + if (lastMessage.role === "tool") { + return "AgentResponse" + } + + return "Default" +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 9893fe2966c..7a8eac19aac 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@roo-code/config-typescript/base.json", "compilerOptions": { "types": ["vitest/globals"], - "outDir": "dist" + "outDir": "dist", + "jsx": "react-jsx", + "jsxImportSource": "react" }, "include": ["src", "*.config.ts"], "exclude": ["node_modules"] diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index f692148c3dd..eff2c14e2c9 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ js: "#!/usr/bin/env node", }, // Bundle workspace packages that export TypeScript - noExternal: ["@roo-code/types", "@roo-code/vscode-shim"], + noExternal: ["@roo-code/core", "@roo-code/core/cli", "@roo-code/types", "@roo-code/vscode-shim"], external: [ // Keep native modules external "@anthropic-ai/sdk", @@ -20,5 +20,12 @@ export default defineConfig({ "@anthropic-ai/vertex-sdk", // Keep @vscode/ripgrep external - we bundle the binary separately "@vscode/ripgrep", + // Optional dev dependency of ink - not needed at runtime + "react-devtools-core", ], + esbuildOptions(options) { + // Enable JSX for React/Ink components + options.jsx = "automatic" + options.jsxImportSource = "react" + }, }) diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index a558a62e83b..63c3348dd0d 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ environment: "node", watch: false, testTimeout: 120_000, // 2m for integration tests. - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], }, }) diff --git a/apps/vscode-e2e/src/suite/modes.test.ts b/apps/vscode-e2e/src/suite/modes.test.ts index 7982f3cf22b..3c9d9a2418e 100644 --- a/apps/vscode-e2e/src/suite/modes.test.ts +++ b/apps/vscode-e2e/src/suite/modes.test.ts @@ -15,15 +15,13 @@ suite("Roo Code Modes", function () { const switchModesTaskId = await globalThis.api.startNewTask({ configuration: { mode: "code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, - text: "For each of `architect`, `ask`, and `debug` use the `switch_mode` tool to switch to that mode.", + text: "Use the `switch_mode` tool to switch to ask mode.", }) await waitUntilCompleted({ api: globalThis.api, taskId: switchModesTaskId }) await globalThis.api.cancelCurrentTask() - assert.ok(modes.includes("architect")) assert.ok(modes.includes("ask")) - assert.ok(modes.includes("debug")) - assert.ok(modes.length === 3) + assert.ok(modes.length === 1) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e46688d3c4..7b792874495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,18 +82,42 @@ importers: apps/cli: dependencies: + '@inkjs/ui': + specifier: ^2.0.0 + version: 2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3)) + '@roo-code/core': + specifier: workspace:^ + version: link:../../packages/core '@roo-code/types': specifier: workspace:^ version: link:../../packages/types '@roo-code/vscode-shim': specifier: workspace:^ version: link:../../packages/vscode-shim + '@trpc/client': + specifier: ^11.8.1 + version: 11.8.1(@trpc/server@11.8.1(typescript@5.8.3))(typescript@5.8.3) '@vscode/ripgrep': specifier: ^1.15.9 version: 1.17.0 commander: specifier: ^12.1.0 version: 12.1.0 + fuzzysort: + specifier: ^3.1.0 + version: 3.1.0 + ink: + specifier: ^6.6.0 + version: 6.6.0(@types/react@18.3.23)(react@19.2.3) + react: + specifier: ^19.1.0 + version: 19.2.3 + superjson: + specifier: ^2.2.6 + version: 2.2.6 + zustand: + specifier: ^5.0.0 + version: 5.0.9(@types/react@18.3.23)(react@19.2.3) devDependencies: '@roo-code/config-eslint': specifier: workspace:^ @@ -104,15 +128,18 @@ importers: '@types/node': specifier: ^24.1.0 version: 24.2.1 + '@types/react': + specifier: ^18.3.23 + version: 18.3.23 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@18.3.23) rimraf: specifier: ^6.0.1 version: 6.0.1 tsup: specifier: ^8.4.0 version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0) - typescript: - specifier: 5.8.3 - version: 5.8.3 vitest: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) @@ -1326,6 +1353,10 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@alcalzone/ansi-tokenize@0.2.3': + resolution: {integrity: sha512-jsElTJ0sQ4wHRz+C45tfect76BwbTbgkgKByOzpCN9xG61N5V6u/glvg1CsNJhq2xJIFpKHSwG3D2wPPuEYOrQ==} + engines: {node: '>=18'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -2169,6 +2200,12 @@ packages: cpu: [x64] os: [win32] + '@inkjs/ui@2.0.0': + resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} + engines: {node: '>=18'} + peerDependencies: + ink: '>=5' + '@inquirer/external-editor@1.0.2': resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==} engines: {node: '>=18'} @@ -4001,6 +4038,17 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@trpc/client@11.8.1': + resolution: {integrity: sha512-L/SJFGanr9xGABmuDoeXR4xAdHJmsXsiF9OuH+apecJ+8sUITzVT1EPeqp0ebqA6lBhEl5pPfg3rngVhi/h60Q==} + peerDependencies: + '@trpc/server': 11.8.1 + typescript: '>=5.7.2' + + '@trpc/server@11.8.1': + resolution: {integrity: sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA==} + peerDependencies: + typescript: '>=5.7.2' + '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -4533,6 +4581,10 @@ packages: resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} engines: {node: '>=18'} + ansi-escapes@7.2.0: + resolution: {integrity: sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4668,6 +4720,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + autoprefixer@10.4.21: resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} engines: {node: ^10 || ^12 || >=14} @@ -4911,6 +4967,10 @@ packages: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -4986,6 +5046,14 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -4994,10 +5062,18 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.3.0: + resolution: {integrity: sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==} + engines: {node: '>=18.20'} + cli-truncate@4.0.0: resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} engines: {node: '>=18'} + cli-truncate@5.1.1: + resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==} + engines: {node: '>=20'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -5040,6 +5116,10 @@ packages: resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} engines: {node: '>=16'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -5137,6 +5217,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -5145,6 +5229,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -5491,6 +5579,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + default-browser-id@5.0.0: resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} engines: {node: '>=18'} @@ -5876,6 +5968,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.43.0: + resolution: {integrity: sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==} + es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -6740,12 +6835,38 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': ^18.3.23 + peerDependenciesMeta: + '@types/react': + optional: true + + ink@6.6.0: + resolution: {integrity: sha512-QDt6FgJxgmSxAelcOvOHUvFxbIUjVpCH5bx+Slvc5m7IEcpGt3dYwbz/L+oRnqEGeRvwy1tineKK4ect3nW1vQ==} + engines: {node: '>=20'} + peerDependencies: + '@types/react': ^18.3.23 + react: '>=19.0.0' + react-devtools-core: ^6.1.2 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + inline-style-parser@0.1.1: resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} @@ -6886,6 +7007,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -6996,6 +7122,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -8279,6 +8409,10 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -8675,6 +8809,12 @@ packages: '@types/react': ^18.3.23 react: '>=18' + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -8755,6 +8895,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -8908,6 +9052,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -9022,6 +9170,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + screenfull@5.2.0: resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} engines: {node: '>=0.10.0'} @@ -9333,6 +9484,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -9473,6 +9628,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -10285,6 +10444,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + windows-release@6.1.0: resolution: {integrity: sha512-1lOb3qdzw6OFmOzoY0nauhLG72TpWtb5qgYPiSh/62rjc1XidBSDio2qw0pwHh17VINF217ebIkZJdFLZFn9SA==} engines: {node: '>=18'} @@ -10453,6 +10616,9 @@ packages: resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} @@ -10490,6 +10656,24 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': ^18.3.23 + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -10497,6 +10681,11 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@alcalzone/ansi-tokenize@0.2.3': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.0.0 + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -11798,6 +11987,14 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inkjs/ui@2.0.0(ink@6.6.0(@types/react@18.3.23)(react@19.2.3))': + dependencies: + chalk: 5.4.1 + cli-spinners: 3.3.0 + deepmerge: 4.3.1 + figures: 6.1.0 + ink: 6.6.0(@types/react@18.3.23)(react@19.2.3) + '@inquirer/external-editor@1.0.2(@types/node@24.2.1)': dependencies: chardet: 2.1.0 @@ -13745,6 +13942,15 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@trpc/client@11.8.1(@trpc/server@11.8.1(typescript@5.8.3))(typescript@5.8.3)': + dependencies: + '@trpc/server': 11.8.1(typescript@5.8.3) + typescript: 5.8.3 + + '@trpc/server@11.8.1(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -14418,6 +14624,10 @@ snapshots: dependencies: environment: 1.1.0 + ansi-escapes@7.2.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -14594,6 +14804,8 @@ snapshots: asynckit@0.4.0: {} + auto-bind@5.0.1: {} + autoprefixer@10.4.21(postcss@8.5.4): dependencies: browserslist: 4.24.5 @@ -14847,6 +15059,8 @@ snapshots: chalk@5.4.1: {} + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@1.1.4: {} @@ -14940,17 +15154,30 @@ snapshots: dependencies: clsx: 2.1.1 + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 cli-spinners@2.9.2: {} + cli-spinners@3.3.0: {} + cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 string-width: 7.2.0 + cli-truncate@5.1.1: + dependencies: + slice-ansi: 7.1.0 + string-width: 8.1.0 + client-only@0.0.1: {} cliui@6.0.0: @@ -15006,6 +15233,10 @@ snapshots: cockatiel@3.2.1: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -15087,10 +15318,16 @@ snapshots: convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -15449,6 +15686,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + default-browser-id@5.0.0: {} default-browser@5.2.1: @@ -15795,6 +16034,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.43.0: {} + es6-error@4.1.1: {} esbuild-register@3.6.0(esbuild@0.25.9): @@ -16921,11 +17162,49 @@ snapshots: indent-string@4.0.0: {} + indent-string@5.0.0: {} + inherits@2.0.4: {} ini@1.3.8: optional: true + ink-testing-library@4.0.0(@types/react@18.3.23): + optionalDependencies: + '@types/react': 18.3.23 + + ink@6.6.0(@types/react@18.3.23)(react@19.2.3): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.3 + ansi-escapes: 7.2.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.1.1 + code-excerpt: 4.0.0 + es-toolkit: 1.43.0 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.3 + react-reconciler: 0.33.0(react@19.2.3) + signal-exit: 3.0.7 + slice-ansi: 7.1.0 + stack-utils: 2.0.6 + string-width: 8.1.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.0 + ws: 8.18.3 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 18.3.23 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.1.1: {} inline-style-parser@0.2.4: {} @@ -17069,6 +17348,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ci@2.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -17158,6 +17439,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-what@5.5.0: {} + is-windows@1.0.2: {} is-wsl@3.1.0: @@ -18767,6 +19050,8 @@ snapshots: partial-json@0.1.7: {} + patch-console@2.0.0: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -19161,6 +19446,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-reconciler@0.33.0(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + react-refresh@0.17.0: {} react-remark@2.1.0(react@18.3.1): @@ -19259,6 +19549,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + react@19.2.3: {} + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -19496,6 +19788,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -19651,6 +19948,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + scheduler@0.27.0: {} + screenfull@5.2.0: {} section-matter@1.0.0: @@ -20018,6 +20317,11 @@ snapshots: get-east-asian-width: 1.3.0 strip-ansi: 7.1.2 + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.2 + string.prototype.codepointat@0.2.1: {} string.prototype.matchall@4.0.12: @@ -20169,6 +20473,10 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -21219,6 +21527,10 @@ snapshots: dependencies: string-width: 4.2.3 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + windows-release@6.1.0: dependencies: execa: 8.0.1 @@ -21361,6 +21673,8 @@ snapshots: yoctocolors@2.1.1: {} + yoga-layout@3.2.1: {} + yoga-wasm-web@0.3.3: {} zip-stream@4.1.1: @@ -21398,4 +21712,9 @@ snapshots: zod@3.25.76: {} + zustand@5.0.9(@types/react@18.3.23)(react@19.2.3): + optionalDependencies: + '@types/react': 18.3.23 + react: 19.2.3 + zwitch@2.0.4: {}