Compare commits

...

30 commits

Author SHA1 Message Date
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
184 changed files with 15222 additions and 1467 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

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

1
apps/cli/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.verify-release/

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

@ -0,0 +1,69 @@
# 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.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,7 +92,7 @@ In interactive mode:
For automation and scripts, use `-y` to auto-approve all actions:
```bash
roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project
roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
```
In non-interactive mode:
@ -99,7 +105,8 @@ In non-interactive mode:
| Option | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |

View file

@ -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.44",
"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,37 @@ check_existing_release() {
# Create GitHub release
create_release() {
step "7/7" "Creating GitHub release..."
step "8/8" "Creating GitHub release..."
cd "$REPO_ROOT"
# Confirm before pushing to GitHub
echo ""
printf "${YELLOW}${BOLD}About to create GitHub release:${NC}\n"
echo " Tag: $TAG"
echo " Version: $VERSION"
echo " Platform: $PLATFORM"
echo " Tarball: $TARBALL"
echo ""
read -p "Push this release to GitHub? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
warn "Aborted by user. Cleaning up..."
cleanup
exit 0
fi
# 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 +451,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
@ -351,8 +525,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", () => {
@ -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)
}
}
},
)

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

File diff suppressed because it is too large Load diff

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,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: "\udb80\ude4b", fallback: "▼" },
file: { nerd: "\udb80\ude14", fallback: "●" },
"file-edit": { nerd: "\uf040", fallback: "✎" },
check: { nerd: "\uf00c", fallback: "✓" },
cross: { nerd: "\uf00d", fallback: "✗" },
"arrow-right": { nerd: "\uf061", fallback: "→" },
bullet: { nerd: "\uf111", fallback: "•" },
spinner: { nerd: "\uf110", fallback: "*" },
// Tool-related icons
search: { nerd: "\uf002", fallback: "🔍" },
terminal: { nerd: "\uf120", fallback: "$" },
browser: { nerd: "\uf0ac", fallback: "🌐" },
switch: { nerd: "\uf074", fallback: "⇄" },
question: { nerd: "\uf128", fallback: "?" },
gear: { nerd: "\uf013", fallback: "⚙" },
diff: { nerd: "\uf46d", fallback: "±" },
// TODO-related icons
checkbox: { nerd: "\uf096", fallback: "○" }, // Empty checkbox
"checkbox-checked": { nerd: "\uf14a", fallback: "✓" }, // Checked checkbox
"checkbox-progress": { nerd: "\uf192", fallback: "→" }, // In progress (dot circle)
"todo-list": { nerd: "\uf0cb", 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 surrogate pairs (length 2)
expect(getIconChar("folder").length).toBe(2)
expect(getIconChar("file").length).toBe(2)
})
it("should respect useNerdFont override", () => {
process.env.ROOCODE_NERD_FONT = "1"
resetNerdFontCache()
// Force fallback
expect(getIconChar("folder", false)).toBe("▼")
process.env.ROOCODE_NERD_FONT = "0"
resetNerdFontCache()
// Force Nerd Font
expect(getIconChar("folder", true).length).toBe(2)
})
it("should return empty string for unknown icon", () => {
// @ts-expect-error - testing invalid icon name
expect(getIconChar("unknown")).toBe("")
})
})
})

View file

@ -0,0 +1,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,108 @@
import { Box, Text } from "ink"
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
/**
* Help shortcut result type.
* Represents a keyboard shortcut or trigger hint.
*/
export interface HelpShortcutResult extends AutocompleteItem {
/** The shortcut key or trigger character */
shortcut: string
/** Description of what the shortcut does */
description: string
}
/**
* Built-in shortcuts to display in the help menu.
*/
const HELP_SHORTCUTS: HelpShortcutResult[] = [
{ key: "slash", shortcut: "/", description: "for commands" },
{ key: "at", shortcut: "@", description: "for file paths" },
{ key: "bang", shortcut: "!", description: "for modes" },
{ key: "newline", shortcut: "shift + ⏎", description: "for newline" },
{ key: "focus", shortcut: "tab", description: "to toggle focus" },
{ key: "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,168 @@
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(8)
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,86 @@
/**
* Renderer for mode and task operations
* Handles: switchMode, newTask, finishTask
*/
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_REASON_LINES = 5
export function ModeTool({ toolData }: ToolRendererProps) {
const iconName = getToolIconName(toolData.tool)
const displayName = getToolDisplayName(toolData.tool)
const mode = toolData.mode || ""
const reason = toolData.reason ? sanitizeContent(toolData.reason) : ""
const content = toolData.content ? sanitizeContent(toolData.content) : ""
const isSwitch = toolData.tool.includes("switch") || toolData.tool.includes("Switch")
const isNewTask = toolData.tool.includes("new") || toolData.tool.includes("New")
const isFinish = toolData.tool.includes("finish") || toolData.tool.includes("Finish")
const { text: previewReason, truncated } = truncateText(reason || content, MAX_REASON_LINES)
return (
<Box flexDirection="column" paddingX={1}>
{/* Header */}
<Box>
<Icon name={iconName} color={theme.toolHeader} />
<Text bold color={theme.toolHeader}>
{" "}
{displayName}
</Text>
</Box>
{/* Mode transition for switch */}
{isSwitch && mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>switching to: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Mode for new task */}
{isNewTask && mode && (
<Box marginLeft={2}>
<Text color={theme.dimText}>mode: </Text>
<Text color={theme.userHeader} bold>
{mode}
</Text>
</Box>
)}
{/* Finish task indicator */}
{isFinish && (
<Box marginLeft={2}>
<Text color={theme.successColor} bold>
Subtask completed
</Text>
</Box>
)}
{/* Reason/message */}
{previewReason && (
<Box flexDirection="column" marginLeft={2} marginTop={1}>
<Text color={theme.dimText}>{isNewTask ? "message:" : "reason:"}</Text>
<Box marginLeft={1}>
<Text color={theme.toolText} italic>
{previewReason}
</Text>
</Box>
{truncated && (
<Text color={theme.dimText} dimColor>
...
</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,127 @@
import { useState, useEffect, useCallback, useRef } from "react"
import { loadHistory, addToHistory } from "../../utils/historyStorage.js"
export interface UseInputHistoryOptions {
isActive?: boolean
getCurrentInput?: () => string
}
export interface UseInputHistoryReturn {
addEntry: (entry: string) => Promise<void>
historyValue: string | null
isBrowsing: boolean
resetBrowsing: (currentInput?: string) => void
history: string[]
draft: string
setDraft: (value: string) => void
navigateUp: () => void
navigateDown: () => void
}
export function useInputHistory(options: UseInputHistoryOptions = {}): UseInputHistoryReturn {
const { isActive = true, getCurrentInput } = options
// All history entries (oldest first, newest at end)
const [history, setHistory] = useState<string[]>([])
// Current position in history (-1 = not browsing, 0 = oldest, history.length-1 = newest)
const [historyIndex, setHistoryIndex] = useState(-1)
// The user's typed text before they started navigating history
const [draft, setDraft] = useState("")
// Flag to track if history has been loaded
const historyLoaded = useRef(false)
// Load history on mount
useEffect(() => {
if (!historyLoaded.current) {
historyLoaded.current = true
loadHistory()
.then(setHistory)
.catch(() => {
// Ignore load errors - history is not critical
})
}
}, [])
// Navigate to older history entry
const navigateUp = useCallback(() => {
if (!isActive) return
if (history.length === 0) return
if (historyIndex === -1) {
// Starting to browse - save current input as draft
if (getCurrentInput) {
setDraft(getCurrentInput())
}
// Go to newest entry
setHistoryIndex(history.length - 1)
} else if (historyIndex > 0) {
// Go to older entry
setHistoryIndex(historyIndex - 1)
}
// At oldest entry - stay there
}, [isActive, history, historyIndex, getCurrentInput])
// Navigate to newer history entry
const navigateDown = useCallback(() => {
if (!isActive) return
if (historyIndex === -1) return // Not browsing
if (historyIndex < history.length - 1) {
// Go to newer entry
setHistoryIndex(historyIndex + 1)
} else {
// At newest entry - return to draft
setHistoryIndex(-1)
}
}, [isActive, historyIndex, history.length])
// Add new entry to history
const addEntry = useCallback(async (entry: string) => {
const trimmed = entry.trim()
if (!trimmed) return
try {
const updated = await addToHistory(trimmed)
setHistory(updated)
} catch {
// Ignore save errors - history is not critical
}
// Reset navigation state
setHistoryIndex(-1)
setDraft("")
}, [])
// Reset browsing state
const resetBrowsing = useCallback((currentInput?: string) => {
setHistoryIndex(-1)
if (currentInput !== undefined) {
setDraft(currentInput)
}
}, [])
// Calculate the current history value to display
// When browsing, show history entry; when returning from browsing, show draft
let historyValue: string | null = null
if (historyIndex >= 0 && historyIndex < history.length) {
historyValue = history[historyIndex] ?? null
}
const isBrowsing = historyIndex !== -1
return {
addEntry,
historyValue,
isBrowsing,
resetBrowsing,
history,
draft,
setDraft,
navigateUp,
navigateDown,
}
}

View file

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

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"

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

@ -0,0 +1,208 @@
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"
/**
* 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) => ({
...initialState,
addMessage: (msg) =>
set((state) => {
// Check if message already exists (by ID).
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
if (existingIndex !== -1) {
// Update existing message in place.
const updated = [...state.messages]
updated[existingIndex] = msg
return { messages: updated }
}
// Add new message.
return { messages: [...state.messages, msg] }
}),
updateMessage: (id, content, partial) =>
set((state) => {
const index = state.messages.findIndex((m) => m.id === id)
if (index === -1) {
return state
}
const existing = state.messages[index]
if (!existing) {
return state
}
const updated = [...state.messages]
updated[index] = {
...existing,
content,
partial: partial !== undefined ? partial : existing.partial,
}
return { messages: updated }
}),
setPendingAsk: (ask) => set({ pendingAsk: ask }),
setLoading: (loading) => set({ isLoading: loading }),
setComplete: (complete) => set({ isComplete: complete }),
setHasStartedTask: (started) => set({ hasStartedTask: started }),
setError: (error) => set({ error }),
reset: () => set(initialState),
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 }),
setFileSearchResults: (results) => set({ fileSearchResults: results }),
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
setAvailableModes: (modes) => set({ availableModes: modes }),
setTaskHistory: (history) => set({ 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 })),
}))

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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