Compare commits

...

43 commits

Author SHA1 Message Date
cte
08869cdc00 Fix release script 2026-01-08 03:19:04 -08:00
cte
68a5352bc6 chore(cli): prepare release v0.0.45 2026-01-08 03:12:37 -08:00
cte
13e3d69a26 style(cli): simplify ModeTool layout to horizontal with mode suffix 2026-01-08 03:10:49 -08:00
cte
9ffe49a22b refactor(cli): remove renderLogger and add performance optimizations
- Remove unused renderLogger.ts utility file and all its usages
- Add RAF-style scroll throttling to reduce state updates
- Stabilize useExtensionHost hook return values with useCallback/useMemo
- Add streaming message debouncing to batch rapid partial updates
- Add shallow array equality checks to prevent unnecessary re-renders
2026-01-08 03:05:05 -08:00
cte
adc1da129c Fix crash 2026-01-08 02:45:46 -08:00
cte
86ca7e4351 test(cli): update tests to match refactored output format
- Update extension-host tests to expect [Tool Request] format
- Update Icon tests to expect single-char Nerd Font icons
2026-01-08 02:38:09 -08:00
cte
8d3a6acb72 refactor(cli): extract hooks, stores, and utils from TUI App component
- Extract extension host logic into useExtensionHost hook
- Extract message handling into useMessageHandlers hook
- Extract task submission into useTaskSubmit hook
- Extract global input handling into useGlobalInput hook
- Extract followup countdown into useFollowupCountdown hook
- Extract focus management into useFocusManagement hook
- Extract picker handlers into usePickerHandlers hook
- Create uiStateStore for UI-specific state (showExitHint, countdown, etc.)
- Extract tool data utilities (extractToolData, formatToolOutput, etc.)
- Extract view utilities (getView function)
- Extract HorizontalLine component
- Reduce App.tsx by ~1400 lines for better maintainability
2026-01-08 02:36:40 -08:00
cte
75cade755e refactor(cli): simplify logging and streamline tool display
- Remove verbose debug logging from extension-host
- Add first/last partial message logging pattern
- Enable alwaysAllowWriteProtected for non-interactive mode
- Add protected file warning in tool approval prompts
- Update Nerd Font icon codepoints in Icon component
- Simplify ModeTool component to essential display only
2026-01-08 01:30:44 -08:00
cte
9fd7112685 Add instructions to push 2026-01-08 00:39:11 -08:00
cte
c194257dac feat(cli): add # shortcut for task history in help trigger 2026-01-08 00:35:09 -08:00
cte
c3c21c696d Update README 2026-01-08 00:17:27 -08:00
cte
bc1246ed21 Get rid of this 2026-01-08 00:06:35 -08:00
cte
c01f10fff7 Remove .gitignore file 2026-01-07 23:55:26 -08:00
cte
40ce8b7dd5 chore(cli): prepare release v0.0.44 2026-01-07 23:53:15 -08:00
cte
7fbd2bee6a Add a release confirmation prompt 2026-01-07 23:51:46 -08:00
cte
c83e67eedb Some cleanup 2026-01-07 23:47:16 -08:00
cte
245008a43c Some cleanup 2026-01-07 23:34:37 -08:00
cte
511586d6bb More progress 2026-01-07 22:34:18 -08:00
cte
607390b94a Task history picker fixes. 2026-01-07 19:18:15 -08:00
cte
4a4156085b Fix history picker bug, fix mode switcher bug 2026-01-07 15:15:04 -08:00
cte
5b5d796d47 Add release helpers 2026-01-07 12:57:14 -08:00
cte
7826828c0a More progress 2026-01-07 12:50:57 -08:00
cte
530b67d2ba Fix tests 2026-01-07 11:24:59 -08:00
cte
616472f807 Remove debug logging 2026-01-07 11:18:20 -08:00
cte
303598f014 More progress 2026-01-07 11:13:37 -08:00
cte
3d7117bb7b More progress 2026-01-07 11:01:06 -08:00
cte
5ec80f94eb Fix tsc errors 2026-01-07 03:03:43 -08:00
cte
58793f4aea A few more tweaks 2026-01-07 03:01:37 -08:00
cte
8072a90a7d More progress 2026-01-07 02:13:01 -08:00
cte
e85362fb11 New task slash command 2026-01-07 01:34:27 -08:00
cte
b6f571cadf More progress 2026-01-06 23:09:38 -08:00
cte
c95706e345 Fix tsc errors 2026-01-06 21:35:14 -08:00
cte
246062c86f Fix tsc errors 2026-01-06 21:30:59 -08:00
cte
c9d52349d5 More progress 2026-01-06 21:28:27 -08:00
cte
a590932727 More progress 2026-01-06 16:22:03 -08:00
cte
262aaa52d1 More progress 2026-01-06 15:39:45 -08:00
cte
0a8f1a13c4 A few more fixes 2026-01-06 03:42:31 -08:00
cte
b4fe095bf1 Merge main 2026-01-06 03:28:44 -08:00
cte
930755e44a Change the text input to multiline 2026-01-06 03:18:45 -08:00
cte
ad6ce88583 Add file picker 2026-01-06 02:59:57 -08:00
cte
c137cf44c8 Fix the build 2026-01-06 01:31:03 -08:00
cte
53ad2e1d6a Add a TUI 2026-01-06 00:49:28 -08:00
cte
a81438de3e Add a cli installer 2026-01-05 16:29:41 -08:00
197 changed files with 15991 additions and 1556 deletions

View file

@ -0,0 +1,82 @@
---
description: "Create a new release of the Roo Code CLI"
argument-hint: "[version-description]"
mode: code
---
1. Identify changes since the last CLI release:
- Get the last CLI release tag: `gh release list --limit 10 | grep "cli-v"`
- View changes since last release: `git log cli-v<last-version>..HEAD -- apps/cli --oneline`
- Or for uncommitted changes: `git diff --stat -- apps/cli`
2. Review and summarize the changes to determine an appropriate changelog entry. Group changes by type:
- **Added**: New features
- **Changed**: Changes to existing functionality
- **Fixed**: Bug fixes
- **Removed**: Removed features
- **Tests**: New or updated tests
3. Bump the version in `apps/cli/package.json`:
- Increment the patch version (e.g., 0.0.43 → 0.0.44) for bug fixes and minor changes
- Increment the minor version (e.g., 0.0.43 → 0.1.0) for new features
- Increment the major version (e.g., 0.0.43 → 1.0.0) for breaking changes
4. Update `apps/cli/CHANGELOG.md` with a new entry:
- Add a new section at the top (below the header) following this format:
```markdown
## [X.Y.Z] - YYYY-MM-DD
### Added
- Description of new features
### Changed
- Description of changes
### Fixed
- Description of bug fixes
```
- Use the current date in YYYY-MM-DD format
- Include links to relevant source files where helpful
- Describe changes from the user's perspective
5. Commit the version bump and changelog update:
```bash
git add apps/cli/package.json apps/cli/CHANGELOG.md
git commit -m "chore(cli): prepare release v<version>"
```
6. Run the release script from the monorepo root:
```bash
./apps/cli/scripts/release.sh
```
The release script will automatically:
- Build the extension and CLI
- Create a platform-specific tarball
- Verify the installation works correctly (runs --help, --version, and e2e test)
- Extract changelog content and include it in the GitHub release notes
- Create the GitHub release with the tarball attached
7. After a successful release, verify:
- Check the release page: https://github.com/RooCodeInc/Roo-Code/releases
- Verify the "What's New" section contains the changelog content
- Test installation: `curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh`
**Notes:**
- The release script requires GitHub CLI (`gh`) to be installed and authenticated
- If a release already exists for the tag, the script will prompt to delete and recreate it
- The script creates a tarball for the current platform only (darwin-arm64, darwin-x64, linux-arm64, or linux-x64)
- Multi-platform releases require running the script on each platform and manually uploading additional tarballs

80
.roo/commands/commit.md Normal file
View file

@ -0,0 +1,80 @@
---
description: "Commit and push changes with a descriptive message"
argument-hint: "[optional-context]"
mode: code
---
1. Analyze the current changes to understand what needs to be committed:
```bash
# Check for staged and unstaged changes
git status --short
# View the diff of all changes (staged and unstaged)
git diff HEAD
```
2. Based on the diff output, formulate a commit message following conventional commit format:
- **feat**: New feature or functionality
- **fix**: Bug fix
- **refactor**: Code restructuring without behavior change
- **docs**: Documentation changes
- **test**: Adding or updating tests
- **chore**: Maintenance tasks, dependencies, configs
- **style**: Formatting, whitespace, no logic changes
Format: `type(scope): brief description`
Examples:
- `feat(api): add user authentication endpoint`
- `fix(ui): resolve button alignment on mobile`
- `refactor(core): simplify error handling logic`
- `docs(readme): update installation instructions`
3. Stage all unstaged changes:
```bash
git add -A
```
4. Commit with the generated message:
```bash
git commit -m "type(scope): brief description"
```
**If pre-commit hooks fail:**
- Review the error output (linter errors, type checking errors, etc.)
- Fix the identified issues in the affected files
- Re-stage the fixes: `git add -A`
- Retry the commit: `git commit -m "type(scope): brief description"`
5. Push to the remote repository:
```bash
git push
```
**If pre-push hooks fail:**
- Review the error output (test failures, linter errors, etc.)
- Fix the identified issues in the affected files
- Stage and commit the fixes using steps 3-4
- Retry the push: `git push`
**Tips for good commit messages:**
- Keep the first line under 72 characters
- Use imperative mood ("add", "fix", "update", not "added", "fixes", "updated")
- Be specific but concise
- If multiple unrelated changes exist, consider splitting into separate commits
**Common hook failures and fixes:**
- **Linter errors**: Run the project's linter (e.g., `npm run lint` or `pnpm lint`) to see all issues, then fix them
- **Type checking errors**: Run type checker (e.g., `npx tsc --noEmit`) to identify type issues
- **Test failures**: Run tests (e.g., `npm test` or `pnpm test`) to identify failing tests and fix them
- **Format issues**: Run formatter (e.g., `npm run format` or `pnpm format`) to auto-fix formatting

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

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

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

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

View file

@ -71,7 +71,13 @@ By default, the CLI prompts for approval before executing actions:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
roo "What is this project?" --workspace ~/Documents/my-project
roo ~/Documents/my-project -P "What is this project?"
```
You can also run without a prompt and enter it interactively in TUI mode:
```bash
roo ~/Documents/my-project
```
In interactive mode:
@ -86,30 +92,33 @@ 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
## Options
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | Provider default |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
| Option | Description | Default |
| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- |
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
| `--no-tui` | Disable TUI, use plain text output | `false` |
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
@ -123,9 +132,7 @@ 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` |
| ... | ... |
## Architecture
@ -166,12 +173,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 +191,17 @@ pnpm lint
## Releasing
To create a new release, run the release script from the monorepo root:
To create a new release, execute the /cli-release slash command:
```bash
# Release using version from package.json
./apps/cli/scripts/release.sh
# Release with a specific version
./apps/cli/scripts/release.sh 0.1.0
roo ~/Documents/Roo-Code -P "/cli-release" -y
```
The script will:
The workflow will:
1. Build the extension and CLI
2. Create a platform-specific tarball (for your current OS/architecture)
3. Create a GitHub release with the tarball attached
**Prerequisites:**
- GitHub CLI (`gh`) installed and authenticated (`gh auth login`)
- pnpm installed
## Troubleshooting
### Extension bundle not found
Make sure you've built the main extension first:
```bash
cd src
pnpm bundle
```
### Module resolution errors
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
### "vscode" module not found
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.
1. Bump the version
2. Update the CHANGELOG
3. Build the extension and CLI
4. Create a platform-specific tarball (for your current OS/architecture)
5. Test the install script
6. Create a GitHub release with the tarball attached

View file

@ -3,9 +3,10 @@
# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
#
# Environment variables:
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
# ROO_VERSION - Specific version to install (default: latest)
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
# ROO_VERSION - Specific version to install (default: latest)
# ROO_LOCAL_TARBALL - Path to local tarball to install (skips download)
set -e
@ -83,6 +84,13 @@ detect_platform() {
# Get latest release version or use specified version
get_version() {
# Skip version fetch if using local tarball
if [ -n "$ROO_LOCAL_TARBALL" ]; then
VERSION="${ROO_VERSION:-local}"
info "Using local tarball (version: $VERSION)"
return
fi
if [ -n "$ROO_VERSION" ]; then
VERSION="$ROO_VERSION"
info "Using specified version: $VERSION"
@ -97,10 +105,10 @@ get_version() {
}
# Extract the latest cli-v* tag
VERSION=$(echo "$RELEASES_JSON" |
grep -o '"tag_name": "cli-v[^"]*"' |
head -1 |
sed 's/"tag_name": "cli-v//' |
VERSION=$(echo "$RELEASES_JSON" |
grep -o '"tag_name": "cli-v[^"]*"' |
head -1 |
sed 's/"tag_name": "cli-v//' |
sed 's/"//')
if [ -z "$VERSION" ]; then
@ -113,27 +121,37 @@ get_version() {
# Download and extract
download_and_install() {
TARBALL="roo-cli-${PLATFORM}.tar.gz"
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
info "Downloading from $URL..."
# Create temp directory
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
# Download with progress indicator
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
if [ "$HTTP_CODE" = "404" ]; then
error "Release not found for platform $PLATFORM version $VERSION.
# Use local tarball if provided, otherwise download
if [ -n "$ROO_LOCAL_TARBALL" ]; then
if [ ! -f "$ROO_LOCAL_TARBALL" ]; then
error "Local tarball not found: $ROO_LOCAL_TARBALL"
fi
info "Using local tarball: $ROO_LOCAL_TARBALL"
cp "$ROO_LOCAL_TARBALL" "$TMP_DIR/$TARBALL"
else
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
info "Downloading from $URL..."
# Download with progress indicator
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
if [ "$HTTP_CODE" = "404" ]; then
error "Release not found for platform $PLATFORM version $VERSION.
Available at: https://github.com/$REPO/releases"
fi
error "Download failed. HTTP code: $HTTP_CODE"
}
fi
error "Download failed. HTTP code: $HTTP_CODE"
}
# Verify we got something
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
error "Downloaded file is empty. Please try again."
# Verify we got something
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
error "Downloaded file is empty. Please try again."
fi
fi
# Remove old installation if exists
@ -260,7 +278,7 @@ print_success() {
echo ""
echo " ${BOLD}Example:${NC}"
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
echo " roo \"What is this project?\" --workspace ~/my-project"
echo " roo ~/my-project -P \"What is this project?\""
echo ""
}

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/cli",
"version": "0.1.0",
"version": "0.0.45",
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
"private": true,
"type": "module",
@ -14,22 +14,31 @@
"check-types": "tsc --noEmit",
"test": "vitest run",
"build": "tsup",
"dev": "tsup --watch",
"start": "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:^",
"@vscode/ripgrep": "^1.15.9",
"commander": "^12.1.0"
"commander": "^12.1.0",
"fuzzysort": "^3.1.0",
"ink": "^6.6.0",
"react": "^19.1.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@roo-code/config-eslint": "workspace:^",
"@roo-code/config-typescript": "workspace:^",
"@types/node": "^24.1.0",
"@types/react": "^19.1.6",
"ink-testing-library": "^4.0.0",
"rimraf": "^6.0.1",
"tsup": "^8.4.0",
"typescript": "5.8.3",
"vitest": "^3.2.3"
}
}

View file

@ -60,7 +60,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"
@ -98,13 +98,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 +170,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 +188,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 +197,12 @@ create_tarball() {
version: pkg.version,
type: 'module',
dependencies: {
commander: pkg.dependencies.commander
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
'commander': pkg.dependencies.commander,
'fuzzysort': pkg.dependencies.fuzzysort,
'ink': pkg.dependencies.ink,
'react': pkg.dependencies.react,
'zustand': pkg.dependencies.zustand
}
};
console.log(JSON.stringify(newPkg, null, 2));
@ -197,6 +260,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 +277,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 +378,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 +398,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 +459,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 +480,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)
@ -351,8 +531,10 @@ main() {
detect_platform
check_prerequisites
get_version "$1"
get_changelog_content
build
create_tarball
verify_local_install
create_checksum
check_existing_release
create_release

View file

@ -1,13 +1,19 @@
// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
import { EventEmitter } from "events"
import type { ProviderName } from "@roo-code/types"
import fs from "fs"
import os from "os"
import path from "path"
import type { ProviderName, WebviewMessage } from "@roo-code/types"
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
vi.mock("@roo-code/vscode-shim", () => ({
createVSCodeAPI: vi.fn(() => ({
context: { extensionPath: "/test/extension" },
})),
setRuntimeConfigValues: vi.fn(),
}))
/**
@ -369,15 +375,15 @@ describe("ExtensionHost", () => {
const emitSpy = vi.spyOn(host, "emit")
// Queue messages before ready
host.sendToExtension({ type: "test1" })
host.sendToExtension({ type: "test2" })
host.sendToExtension({ type: "requestModes" })
host.sendToExtension({ type: "requestCommands" })
// Mark ready (should flush)
host.markWebviewReady()
// Check that webviewMessage events were emitted for pending messages
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" })
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
})
})
})
@ -385,7 +391,7 @@ describe("ExtensionHost", () => {
describe("sendToExtension", () => {
it("should queue message when webview not ready", () => {
const host = createTestHost()
const message = { type: "test" }
const message: WebviewMessage = { type: "requestModes" }
host.sendToExtension(message)
@ -396,7 +402,7 @@ describe("ExtensionHost", () => {
it("should emit webviewMessage event when webview is ready", () => {
const host = createTestHost()
const emitSpy = vi.spyOn(host, "emit")
const message = { type: "test" }
const message: WebviewMessage = { type: "requestModes" }
host.markWebviewReady()
host.sendToExtension(message)
@ -408,7 +414,7 @@ describe("ExtensionHost", () => {
const host = createTestHost()
host.markWebviewReady()
host.sendToExtension({ type: "test" })
host.sendToExtension({ type: "requestModes" })
const pending = getPrivate<unknown[]>(host, "pendingMessages")
expect(pending).toHaveLength(0)
@ -433,24 +439,6 @@ describe("ExtensionHost", () => {
expect(handleMsgUpdatedSpy).toHaveBeenCalled()
})
it("should route action messages to handleActionMessage", () => {
const host = createTestHost()
const handleActionSpy = spyOnPrivate(host, "handleActionMessage")
callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" })
expect(handleActionSpy).toHaveBeenCalled()
})
it("should route invoke messages to handleInvokeMessage", () => {
const host = createTestHost()
const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage")
callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" })
expect(handleInvokeSpy).toHaveBeenCalled()
})
})
describe("handleSayMessage", () => {
@ -581,7 +569,7 @@ describe("ExtensionHost", () => {
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file")
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] write_file")
expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt")
})
@ -594,7 +582,7 @@ describe("ExtensionHost", () => {
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("\n[Tool Request] write_file")
expect(outputSpy).toHaveBeenCalledWith(
" content: This is the content that will be written to the file. It might be long.",
)
@ -603,7 +591,7 @@ describe("ExtensionHost", () => {
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")
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] unknown")
})
it("should not display duplicate messages for same ts", () => {
@ -810,7 +798,7 @@ describe("ExtensionHost", () => {
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
// Should show prompt with timeout hint
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s"))
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 60s"))
})
})
@ -1144,21 +1132,362 @@ describe("ExtensionHost", () => {
await expect(promise).rejects.toThrow("Test error")
})
})
it("should timeout after configured duration", async () => {
const host = createTestHost()
describe("handleStateMessage - mode tracking", () => {
let host: ExtensionHost
// Use fake timers for this test
vi.useFakeTimers()
beforeEach(() => {
host = createTestHost({
mode: "code",
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Mock process.stdout.write which is used by output()
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
})
const promise = callPrivate<Promise<void>>(host, "waitForCompletion")
afterEach(() => {
vi.restoreAllMocks()
})
// Fast-forward past the timeout (10 minutes)
vi.advanceTimersByTime(10 * 60 * 1000 + 1)
it("should track current mode when state updates with a mode", () => {
// Initial state update establishes current mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
await expect(promise).rejects.toThrow("Task timed out")
// Second state update should update tracked mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("architect")
})
vi.useRealTimers()
it("should not change current mode when state has no mode", () => {
// Initial state update establishes current mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// State without mode should not change tracked mode
callPrivate(host, "handleStateMessage", { type: "state", state: { clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
})
it("should track current mode across multiple changes", () => {
// Start with code mode
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("code")
// Change to architect
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("architect")
// Change to debug
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("debug")
// Another state update with debug
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "debug", clineMessages: [] } })
expect(getPrivate(host, "currentMode")).toBe("debug")
})
it("should not send updateSettings on mode change (CLI settings are applied once during runTask)", () => {
// This test ensures mode changes don't trigger automatic re-application of API settings.
// CLI settings are applied once during runTask() via updateSettings.
// Mode-specific provider profiles are handled by the extension's handleModeSwitch.
const sendToExtensionSpy = vi.spyOn(host, "sendToExtension")
// Initial state
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
sendToExtensionSpy.mockClear()
// Mode change should NOT trigger sendToExtension
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
expect(sendToExtensionSpy).not.toHaveBeenCalled()
})
})
describe("applyRuntimeSettings - mode switching", () => {
it("should use currentMode when set (from user mode switches)", () => {
const host = createTestHost({
mode: "code", // Initial mode from CLI options
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Simulate user switching mode via Ctrl+M - this updates currentMode
;(host as unknown as Record<string, unknown>).currentMode = "architect"
// Create settings object to be modified
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use currentMode (architect), not options.mode (code)
expect(settings.mode).toBe("architect")
})
it("should fall back to options.mode when currentMode is not set", () => {
const host = createTestHost({
mode: "code",
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// currentMode is not set (still null from constructor)
expect(getPrivate(host, "currentMode")).toBe("code") // Set from options.mode in constructor
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use options.mode as fallback
expect(settings.mode).toBe("code")
})
it("should use currentMode even when it differs from initial options.mode", () => {
const host = createTestHost({
mode: "code",
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Simulate multiple mode switches: code -> architect -> debug
;(host as unknown as Record<string, unknown>).currentMode = "debug"
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Should use the latest currentMode
expect(settings.mode).toBe("debug")
})
it("should not set mode if neither currentMode nor options.mode is set", () => {
const host = createTestHost({
// No mode specified - mode defaults to "code" in createTestHost
apiProvider: "anthropic",
apiKey: "test-key",
model: "test-model",
})
// Explicitly set currentMode to null (edge case)
;(host as unknown as Record<string, unknown>).currentMode = null
// Also clear options.mode
const options = getPrivate<ExtensionHostOptions>(host, "options")
options.mode = ""
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
// Mode should not be set
expect(settings.mode).toBeUndefined()
})
})
describe("mode switching - end to end simulation", () => {
let host: ExtensionHost
beforeEach(() => {
host = createTestHost({
mode: "code",
apiProvider: "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, "handleStateMessage", {
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, "handleStateMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
expect(getPrivate(host, "currentMode")).toBe("architect")
// Step 3: When runTask is called, applyRuntimeSettings should use architect
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("architect")
})
it("should handle mode switch before any state messages", () => {
// currentMode is initialized to options.mode in constructor
expect(getPrivate(host, "currentMode")).toBe("code")
// Without any state messages, should still use options.mode
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("code")
})
it("should track multiple mode switches correctly", () => {
// Switch through multiple modes
callPrivate(host, "handleStateMessage", {
type: "state",
state: { mode: "code", clineMessages: [] },
})
callPrivate(host, "handleStateMessage", {
type: "state",
state: { mode: "architect", clineMessages: [] },
})
callPrivate(host, "handleStateMessage", {
type: "state",
state: { mode: "debug", clineMessages: [] },
})
callPrivate(host, "handleStateMessage", {
type: "state",
state: { mode: "ask", clineMessages: [] },
})
// Should use the most recent mode
expect(getPrivate(host, "currentMode")).toBe("ask")
const settings: Record<string, unknown> = {}
callPrivate(host, "applyRuntimeSettings", settings)
expect(settings.mode).toBe("ask")
})
})
describe("ephemeral mode", () => {
describe("constructor", () => {
it("should store ephemeral option", () => {
const host = createTestHost({ ephemeral: true })
const options = getPrivate<ExtensionHostOptions>(host, "options")
expect(options.ephemeral).toBe(true)
})
it("should default ephemeral to undefined", () => {
const host = createTestHost()
const options = getPrivate<ExtensionHostOptions>(host, "options")
expect(options.ephemeral).toBeUndefined()
})
it("should initialize ephemeralStorageDir to null", () => {
const host = createTestHost({ ephemeral: true })
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
})
})
describe("createEphemeralStorageDir", () => {
let createdDirs: string[] = []
afterEach(async () => {
// Clean up any directories created during tests
for (const dir of createdDirs) {
try {
await fs.promises.rm(dir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
}
createdDirs = []
})
it("should create a directory in the system temp folder", async () => {
const host = createTestHost({ ephemeral: true })
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(tmpDir)
expect(tmpDir).toContain(os.tmpdir())
expect(tmpDir).toContain("roo-cli-")
expect(fs.existsSync(tmpDir)).toBe(true)
})
it("should create a unique directory each time", async () => {
const host = createTestHost({ ephemeral: true })
const dir1 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
const dir2 = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(dir1, dir2)
expect(dir1).not.toBe(dir2)
expect(fs.existsSync(dir1)).toBe(true)
expect(fs.existsSync(dir2)).toBe(true)
})
it("should include timestamp and random id in directory name", async () => {
const host = createTestHost({ ephemeral: true })
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
createdDirs.push(tmpDir)
const dirName = path.basename(tmpDir)
// Format: roo-cli-{timestamp}-{randomId}
expect(dirName).toMatch(/^roo-cli-\d+-[a-z0-9]+$/)
})
})
describe("dispose - ephemeral cleanup", () => {
it("should clean up ephemeral storage directory on dispose", async () => {
const host = createTestHost({ ephemeral: true })
// Create the ephemeral directory
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
// Verify directory exists
expect(fs.existsSync(tmpDir)).toBe(true)
// Dispose the host
await host.dispose()
// Directory should be removed
expect(fs.existsSync(tmpDir)).toBe(false)
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
})
it("should not fail dispose if ephemeral directory doesn't exist", async () => {
const host = createTestHost({ ephemeral: true })
// Set a non-existent directory
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = "/non/existent/path/roo-cli-test"
// Dispose should not throw
await expect(host.dispose()).resolves.toBeUndefined()
})
it("should clean up ephemeral directory with contents", async () => {
const host = createTestHost({ ephemeral: true })
// Create the ephemeral directory with some content
const tmpDir = await callPrivate<Promise<string>>(host, "createEphemeralStorageDir")
;(host as unknown as Record<string, unknown>).ephemeralStorageDir = tmpDir
// Add some files and subdirectories
await fs.promises.writeFile(path.join(tmpDir, "test.txt"), "test content")
await fs.promises.mkdir(path.join(tmpDir, "subdir"))
await fs.promises.writeFile(path.join(tmpDir, "subdir", "nested.txt"), "nested content")
// Verify content exists
expect(fs.existsSync(path.join(tmpDir, "test.txt"))).toBe(true)
expect(fs.existsSync(path.join(tmpDir, "subdir", "nested.txt"))).toBe(true)
// Dispose the host
await host.dispose()
// Directory and all contents should be removed
expect(fs.existsSync(tmpDir)).toBe(false)
})
it("should not clean up anything if not in ephemeral mode", async () => {
const host = createTestHost({ ephemeral: false })
// ephemeralStorageDir should be null
expect(getPrivate(host, "ephemeralStorageDir")).toBeNull()
// Dispose should complete normally
await expect(host.dispose()).resolves.toBeUndefined()
})
})
})
})

View file

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

File diff suppressed because it is too large Load diff

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
</Box>
{/* Path if present */}
{path && (
<Box marginLeft={2}>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text} bold>
{path}
</Text>
{toolData.isOutsideWorkspace && (
<Text color={theme.warningColor} dimColor>
{" "}
outside workspace
</Text>
)}
{toolData.isProtected && <Text color={theme.errorColor}> 🔒 protected</Text>}
</Box>
)}
{/* Mode if present */}
{mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>mode: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Content */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={path || mode ? 1 : 0}>
{previewContent.split("\n").map((line, i) => (
<Text key={i} color={theme.toolText}>
{line}
</Text>
))}
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more lines)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="row" gap={1} paddingX={1} marginBottom={1}>
<Icon name={iconName} color={theme.toolHeader} />
{isSwitch && mode && (
<Box gap={1}>
<Text color={theme.dimText}>Switching to</Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
<Text color={theme.dimText}>mode</Text>
</Box>
)}
</Box>
)
}

View file

@ -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 (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
{matchCount > 0 && <Text color={theme.dimText}> ({matchCount} matches)</Text>}
</Box>
{/* Search parameters */}
<Box flexDirection="column" marginLeft={2}>
{/* Regex/Query */}
{regex && (
<Box>
<Text color={theme.dimText}>regex: </Text>
<Text color={theme.warningColor} bold>
{regex}
</Text>
</Box>
)}
{query && (
<Box>
<Text color={theme.dimText}>query: </Text>
<Text color={theme.warningColor} bold>
{query}
</Text>
</Box>
)}
{/* Search scope */}
<Box>
{path && (
<>
<Text color={theme.dimText}>path: </Text>
<Text color={theme.text}>{path}</Text>
</>
)}
{filePattern && (
<>
<Text color={theme.dimText}> pattern: </Text>
<Text color={theme.text}>{filePattern}</Text>
</>
)}
</Box>
</Box>
{/* Results */}
{previewContent && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.dimText} bold>
Results:
</Text>
<Box flexDirection="column" marginTop={0}>
{previewContent.split("\n").map((line, i) => {
// Try to highlight file:line patterns
const match = line.match(/^([^:]+):(\d+):(.*)$/)
if (match) {
const [, file, lineNum, context] = match
return (
<Box key={i}>
<Text color={theme.focusColor}>{file}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.warningColor}>{lineNum}</Text>
<Text color={theme.dimText}>:</Text>
<Text color={theme.toolText}>{context}</Text>
</Box>
)
}
return (
<Text key={i} color={theme.toolText}>
{line}
</Text>
)
})}
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
... ({hiddenLines} more results)
</Text>
)}
</Box>
)}
</Box>
)
}

View file

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

View file

@ -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<string, React.FC<ToolRendererProps>> = {
"file-read": FileReadTool,
"file-write": FileWriteTool,
search: SearchTool,
command: CommandTool,
browser: BrowserTool,
mode: ModeTool,
completion: CompletionTool,
other: GenericTool,
}
/**
* Get the appropriate renderer component for a tool
*
* @param toolName - The tool name/identifier
* @returns The renderer component for this tool type
*/
export function getToolRenderer(toolName: string): React.FC<ToolRendererProps> {
const category = getToolCategory(toolName)
return CATEGORY_RENDERERS[category] || GenericTool
}

View file

@ -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"
}

View file

@ -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<string, string> = {
// 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<string, IconName> = {
// 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
}

View file

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

View file

@ -0,0 +1,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)
})
})
})

View file

@ -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"

View file

@ -0,0 +1,206 @@
import { useEffect, useRef, useCallback, useMemo } from "react"
import { useApp } from "ink"
import { randomUUID } from "crypto"
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
import { toolInspectorLog, clearToolInspectorLog } from "../../utils/toolInspectorLogger.js"
import { useCLIStore } from "../store.js"
interface ExtensionHostInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, handler: (...args: any[]) => void): void
activate(): Promise<void>
runTask(prompt: string): Promise<void>
sendToExtension(message: WebviewMessage): void
dispose(): Promise<void>
}
export interface ExtensionHostOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
debug: boolean
nonInteractive: boolean
ephemeral?: boolean
}
export interface UseExtensionHostOptions extends ExtensionHostOptions {
initialPrompt?: string
exitOnComplete?: boolean
onExtensionMessage: (msg: ExtensionMessage) => void
createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface
}
interface ExtensionHostFactoryOptions {
mode: string
reasoningEffort?: string
apiProvider: string
apiKey: string
model: string
workspacePath: string
extensionPath: string
verbose: boolean
quiet: boolean
nonInteractive: boolean
disableOutput: boolean
ephemeral?: boolean
}
export interface UseExtensionHostReturn {
isReady: boolean
sendToExtension: ((msg: WebviewMessage) => void) | null
runTask: ((prompt: string) => Promise<void>) | null
cleanup: () => Promise<void>
}
/**
* Hook to manage the extension host lifecycle.
*
* Responsibilities:
* - Initialize the extension host
* - Set up event listeners for messages, task completion, and errors
* - Handle cleanup/disposal
* - Expose methods for sending messages and running tasks
*/
export function useExtensionHost({
initialPrompt,
mode,
reasoningEffort,
apiProvider,
apiKey,
model,
workspacePath,
extensionPath,
verbose,
debug,
nonInteractive,
ephemeral,
exitOnComplete,
onExtensionMessage,
createExtensionHost,
}: UseExtensionHostOptions): UseExtensionHostReturn {
const { exit } = useApp()
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
const hostRef = useRef<ExtensionHostInterface | null>(null)
const isReadyRef = useRef(false)
// Cleanup function
const cleanup = useCallback(async () => {
if (hostRef.current) {
await hostRef.current.dispose()
hostRef.current = null
isReadyRef.current = false
}
}, [])
// Initialize extension host
useEffect(() => {
const init = async () => {
// Clear tool inspector log for fresh session
clearToolInspectorLog()
toolInspectorLog("session:start", {
timestamp: new Date().toISOString(),
mode,
nonInteractive,
})
try {
const host = createExtensionHost({
mode,
reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort,
apiProvider,
apiKey,
model,
workspacePath,
extensionPath,
verbose: debug,
quiet: !verbose && !debug,
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<void> => {
if (!hostRef.current) {
return Promise.reject(new Error("Extension host not ready"))
}
return hostRef.current.runTask(prompt)
}, [])
// Memoized return object to prevent unnecessary re-renders in consumers
return useMemo(
() => ({
isReady: isReadyRef.current,
sendToExtension,
runTask,
cleanup,
}),
[sendToExtension, runTask, cleanup],
)
}

View file

@ -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,
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,23 @@
// Main App
export { type TUIAppProps, App } from "./App.js"
// Components
export { default as Header } from "./components/Header.js"
export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js"
export { default as LoadingText } from "./components/LoadingText.js"
// Autocomplete
export * from "./components/autocomplete/index.js"
// Hooks
export { useInputHistory } from "./hooks/useInputHistory.js"
export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useInputHistory.js"
// Store
export { useCLIStore } from "./store.js"
// Theme
export * as theme from "./theme.js"
// Types
export * from "./types.js"

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

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

View file

@ -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<any>
}
interface UIActions {
// Exit handling actions
setShowExitHint: (show: boolean) => void
setPendingExit: (pending: boolean) => void
// Countdown timer actions
setCountdownSeconds: (seconds: number | null) => void
// Custom input mode actions
setShowCustomInput: (show: boolean) => void
setIsTransitioningToCustomInput: (transitioning: boolean) => void
// Focus management actions
setManualFocus: (focus: "scroll" | "input" | null) => void
// TODO viewer actions
setShowTodoViewer: (show: boolean) => void
// Picker state actions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPickerState: (state: AutocompletePickerState<any>) => void
// Reset all UI state to defaults
resetUIState: () => void
}
const initialState: UIState = {
showExitHint: false,
pendingExit: false,
countdownSeconds: null,
showCustomInput: false,
isTransitioningToCustomInput: false,
manualFocus: null,
showTodoViewer: false,
pickerState: {
activeTrigger: null,
results: [],
selectedIndex: 0,
isOpen: false,
isLoading: false,
triggerInfo: null,
},
}
export const useUIStateStore = create<UIState & UIActions>((set) => ({
...initialState,
setShowExitHint: (show) => set({ showExitHint: show }),
setPendingExit: (pending) => set({ pendingExit: pending }),
setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }),
setShowCustomInput: (show) => set({ showCustomInput: show }),
setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }),
setManualFocus: (focus) => set({ manualFocus: focus }),
setShowTodoViewer: (show) => set({ showTodoViewer: show }),
setPickerState: (state) => set({ pickerState: state }),
resetUIState: () => set(initialState),
}))

79
apps/cli/src/ui/theme.ts Normal file
View file

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

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

@ -0,0 +1,140 @@
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 interface AppProps {
initialPrompt: string
workspacePath: string
extensionPath: string
apiProvider: string
apiKey: string
model: string
mode: string
nonInteractive: boolean
verbose: boolean
debug: boolean
exitOnComplete: boolean
reasoningEffort?: string
ephemeral?: boolean
version: string
}
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
export interface TaskHistoryItem {
id: string
task: string
ts: number
totalCost?: number
workspace?: string
mode?: string
status?: "active" | "completed" | "delegated"
tokensIn?: number
tokensOut?: number
}

View file

@ -0,0 +1,9 @@
export {
extractToolData,
formatToolOutput,
formatToolAskMessage,
parseTodosFromToolInfo,
parseMarkdownChecklist,
} from "./toolDataUtils.js"
export { getView } from "./viewUtils.js"

View file

@ -0,0 +1,345 @@
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<string, unknown>): 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<Record<string, unknown>>).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<Record<string, unknown>>).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, unknown>): 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, unknown>): 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<string, unknown>): 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<string, unknown>
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
}

View file

@ -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"
}

View file

@ -1,12 +1,8 @@
/**
* Unit tests for CLI utility functions
*/
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
import fs from "fs"
import path from "path"
// Mock fs module
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
vi.mock("fs")
describe("getEnvVarName", () => {
@ -80,8 +76,17 @@ describe("getApiKeyFromEnv", () => {
})
describe("getDefaultExtensionPath", () => {
const originalEnv = process.env
beforeEach(() => {
vi.resetAllMocks()
// Reset process.env to avoid ROO_EXTENSION_PATH from installed CLI affecting tests
process.env = { ...originalEnv }
delete process.env.ROO_EXTENSION_PATH
})
afterEach(() => {
process.env = originalEnv
})
it("should return monorepo path when extension.js exists there", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,57 @@
import { normalizePath, arePathsEqual } from "./pathUtils.js"
describe("normalizePath", () => {
it("should remove trailing slashes", () => {
expect(normalizePath("/Users/test/project/")).toBe("/Users/test/project")
expect(normalizePath("/Users/test/project//")).toBe("/Users/test/project")
})
it("should handle paths without trailing slashes", () => {
expect(normalizePath("/Users/test/project")).toBe("/Users/test/project")
})
it("should normalize path separators", () => {
// path.normalize handles this
expect(normalizePath("/Users//test/project")).toBe("/Users/test/project")
})
})
describe("arePathsEqual", () => {
it("should return true for identical paths", () => {
expect(arePathsEqual("/Users/test/project", "/Users/test/project")).toBe(true)
})
it("should return true for paths differing only by trailing slash", () => {
expect(arePathsEqual("/Users/test/project", "/Users/test/project/")).toBe(true)
expect(arePathsEqual("/Users/test/project/", "/Users/test/project")).toBe(true)
})
it("should return false for undefined or empty paths", () => {
expect(arePathsEqual(undefined, "/Users/test/project")).toBe(false)
expect(arePathsEqual("/Users/test/project", undefined)).toBe(false)
expect(arePathsEqual(undefined, undefined)).toBe(false)
expect(arePathsEqual("", "/Users/test/project")).toBe(false)
expect(arePathsEqual("/Users/test/project", "")).toBe(false)
})
it("should return false for different paths", () => {
expect(arePathsEqual("/Users/test/project1", "/Users/test/project2")).toBe(false)
expect(arePathsEqual("/Users/test/project", "/Users/other/project")).toBe(false)
})
// Case sensitivity behavior depends on platform
if (process.platform === "darwin" || process.platform === "win32") {
it("should be case-insensitive on macOS/Windows", () => {
expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(true)
expect(arePathsEqual("/USERS/TEST/PROJECT", "/Users/test/project")).toBe(true)
})
} else {
it("should be case-sensitive on Linux", () => {
expect(arePathsEqual("/Users/Test/Project", "/users/test/project")).toBe(false)
})
}
it("should handle paths with multiple trailing slashes", () => {
expect(arePathsEqual("/Users/test/project///", "/Users/test/project")).toBe(true)
})
})

View file

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

View file

@ -0,0 +1,66 @@
/**
* Tool Inspector Logger
*
* A dedicated logger for inspecting tool use payloads in the CLI.
* This writes to ~/.roo/cli-tool-inspector.log, separate from the general
* debug log to avoid noise when specifically investigating tool shapes.
*
* Usage:
* import { toolInspectorLog } from "../utils/toolInspectorLogger.js"
*
* toolInspectorLog("tool:received", { toolName, payload })
*/
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
const TOOL_INSPECTOR_LOG_PATH = path.join(os.homedir(), ".roo", "cli-tool-inspector.log")
/**
* Log a tool inspection entry to the dedicated log file.
* Writes timestamped JSON entries to ~/.roo/cli-tool-inspector.log
*/
export function toolInspectorLog(event: string, data?: unknown): void {
try {
const logDir = path.dirname(TOOL_INSPECTOR_LOG_PATH)
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true })
}
const timestamp = new Date().toISOString()
const entry = {
timestamp,
event,
...(data !== undefined && { data }),
}
// Write as formatted JSON for easier inspection
fs.appendFileSync(TOOL_INSPECTOR_LOG_PATH, JSON.stringify(entry, null, 2) + "\n---\n")
} catch {
// NO-OP - don't let logging errors break functionality
}
}
/**
* Clear the tool inspector log file.
* Useful for starting a fresh inspection session.
*/
export function clearToolInspectorLog(): void {
try {
if (fs.existsSync(TOOL_INSPECTOR_LOG_PATH)) {
fs.unlinkSync(TOOL_INSPECTOR_LOG_PATH)
}
} catch {
// NO-OP
}
}
/**
* Get the path to the tool inspector log file.
*/
export function getToolInspectorLogPath(): string {
return TOOL_INSPECTOR_LOG_PATH
}

View file

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

View file

@ -12,7 +12,7 @@ export default defineConfig({
js: "#!/usr/bin/env node",
},
// Bundle workspace packages that export TypeScript
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
noExternal: ["@roo-code/core", "@roo-code/core/message-utils", "@roo-code/types", "@roo-code/vscode-shim"],
external: [
// Keep native modules external
"@anthropic-ai/sdk",
@ -20,5 +20,12 @@ export default defineConfig({
"@anthropic-ai/vertex-sdk",
// Keep @vscode/ripgrep external - we bundle the binary separately
"@vscode/ripgrep",
// Optional dev dependency of ink - not needed at runtime
"react-devtools-core",
],
esbuildOptions(options) {
// Enable JSX for React/Ink components
options.jsx = "automatic"
options.jsxImportSource = "react"
},
})

View file

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

View file

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

View file

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

View file

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

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