mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Compare commits
43 commits
main
...
cli-v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08869cdc00 | ||
|
|
68a5352bc6 | ||
|
|
13e3d69a26 | ||
|
|
9ffe49a22b | ||
|
|
adc1da129c | ||
|
|
86ca7e4351 | ||
|
|
8d3a6acb72 | ||
|
|
75cade755e | ||
|
|
9fd7112685 | ||
|
|
c194257dac | ||
|
|
c3c21c696d | ||
|
|
bc1246ed21 | ||
|
|
c01f10fff7 | ||
|
|
40ce8b7dd5 | ||
|
|
7fbd2bee6a | ||
|
|
c83e67eedb | ||
|
|
245008a43c | ||
|
|
511586d6bb | ||
|
|
607390b94a | ||
|
|
4a4156085b | ||
|
|
5b5d796d47 | ||
|
|
7826828c0a | ||
|
|
530b67d2ba | ||
|
|
616472f807 | ||
|
|
303598f014 | ||
|
|
3d7117bb7b | ||
|
|
5ec80f94eb | ||
|
|
58793f4aea | ||
|
|
8072a90a7d | ||
|
|
e85362fb11 | ||
|
|
b6f571cadf | ||
|
|
c95706e345 | ||
|
|
246062c86f | ||
|
|
c9d52349d5 | ||
|
|
a590932727 | ||
|
|
262aaa52d1 | ||
|
|
0a8f1a13c4 | ||
|
|
b4fe095bf1 | ||
|
|
930755e44a | ||
|
|
ad6ce88583 | ||
|
|
c137cf44c8 | ||
|
|
53ad2e1d6a | ||
|
|
a81438de3e |
197 changed files with 15991 additions and 1556 deletions
82
.roo/commands/cli-release.md
Normal file
82
.roo/commands/cli-release.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
---
|
||||||
|
description: "Create a new release of the Roo Code CLI"
|
||||||
|
argument-hint: "[version-description]"
|
||||||
|
mode: code
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Identify changes since the last CLI release:
|
||||||
|
|
||||||
|
- Get the last CLI release tag: `gh release list --limit 10 | grep "cli-v"`
|
||||||
|
- View changes since last release: `git log cli-v<last-version>..HEAD -- apps/cli --oneline`
|
||||||
|
- Or for uncommitted changes: `git diff --stat -- apps/cli`
|
||||||
|
|
||||||
|
2. Review and summarize the changes to determine an appropriate changelog entry. Group changes by type:
|
||||||
|
|
||||||
|
- **Added**: New features
|
||||||
|
- **Changed**: Changes to existing functionality
|
||||||
|
- **Fixed**: Bug fixes
|
||||||
|
- **Removed**: Removed features
|
||||||
|
- **Tests**: New or updated tests
|
||||||
|
|
||||||
|
3. Bump the version in `apps/cli/package.json`:
|
||||||
|
|
||||||
|
- Increment the patch version (e.g., 0.0.43 → 0.0.44) for bug fixes and minor changes
|
||||||
|
- Increment the minor version (e.g., 0.0.43 → 0.1.0) for new features
|
||||||
|
- Increment the major version (e.g., 0.0.43 → 1.0.0) for breaking changes
|
||||||
|
|
||||||
|
4. Update `apps/cli/CHANGELOG.md` with a new entry:
|
||||||
|
|
||||||
|
- Add a new section at the top (below the header) following this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [X.Y.Z] - YYYY-MM-DD
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Description of new features
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Description of changes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Description of bug fixes
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use the current date in YYYY-MM-DD format
|
||||||
|
- Include links to relevant source files where helpful
|
||||||
|
- Describe changes from the user's perspective
|
||||||
|
|
||||||
|
5. Commit the version bump and changelog update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/cli/package.json apps/cli/CHANGELOG.md
|
||||||
|
git commit -m "chore(cli): prepare release v<version>"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Run the release script from the monorepo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./apps/cli/scripts/release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The release script will automatically:
|
||||||
|
|
||||||
|
- Build the extension and CLI
|
||||||
|
- Create a platform-specific tarball
|
||||||
|
- Verify the installation works correctly (runs --help, --version, and e2e test)
|
||||||
|
- Extract changelog content and include it in the GitHub release notes
|
||||||
|
- Create the GitHub release with the tarball attached
|
||||||
|
|
||||||
|
7. After a successful release, verify:
|
||||||
|
- Check the release page: https://github.com/RooCodeInc/Roo-Code/releases
|
||||||
|
- Verify the "What's New" section contains the changelog content
|
||||||
|
- Test installation: `curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh`
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- The release script requires GitHub CLI (`gh`) to be installed and authenticated
|
||||||
|
- If a release already exists for the tag, the script will prompt to delete and recreate it
|
||||||
|
- The script creates a tarball for the current platform only (darwin-arm64, darwin-x64, linux-arm64, or linux-x64)
|
||||||
|
- Multi-platform releases require running the script on each platform and manually uploading additional tarballs
|
||||||
80
.roo/commands/commit.md
Normal file
80
.roo/commands/commit.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
description: "Commit and push changes with a descriptive message"
|
||||||
|
argument-hint: "[optional-context]"
|
||||||
|
mode: code
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Analyze the current changes to understand what needs to be committed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for staged and unstaged changes
|
||||||
|
git status --short
|
||||||
|
|
||||||
|
# View the diff of all changes (staged and unstaged)
|
||||||
|
git diff HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Based on the diff output, formulate a commit message following conventional commit format:
|
||||||
|
|
||||||
|
- **feat**: New feature or functionality
|
||||||
|
- **fix**: Bug fix
|
||||||
|
- **refactor**: Code restructuring without behavior change
|
||||||
|
- **docs**: Documentation changes
|
||||||
|
- **test**: Adding or updating tests
|
||||||
|
- **chore**: Maintenance tasks, dependencies, configs
|
||||||
|
- **style**: Formatting, whitespace, no logic changes
|
||||||
|
|
||||||
|
Format: `type(scope): brief description`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `feat(api): add user authentication endpoint`
|
||||||
|
- `fix(ui): resolve button alignment on mobile`
|
||||||
|
- `refactor(core): simplify error handling logic`
|
||||||
|
- `docs(readme): update installation instructions`
|
||||||
|
|
||||||
|
3. Stage all unstaged changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Commit with the generated message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "type(scope): brief description"
|
||||||
|
```
|
||||||
|
|
||||||
|
**If pre-commit hooks fail:**
|
||||||
|
|
||||||
|
- Review the error output (linter errors, type checking errors, etc.)
|
||||||
|
- Fix the identified issues in the affected files
|
||||||
|
- Re-stage the fixes: `git add -A`
|
||||||
|
- Retry the commit: `git commit -m "type(scope): brief description"`
|
||||||
|
|
||||||
|
5. Push to the remote repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
**If pre-push hooks fail:**
|
||||||
|
|
||||||
|
- Review the error output (test failures, linter errors, etc.)
|
||||||
|
- Fix the identified issues in the affected files
|
||||||
|
- Stage and commit the fixes using steps 3-4
|
||||||
|
- Retry the push: `git push`
|
||||||
|
|
||||||
|
**Tips for good commit messages:**
|
||||||
|
|
||||||
|
- Keep the first line under 72 characters
|
||||||
|
- Use imperative mood ("add", "fix", "update", not "added", "fixes", "updated")
|
||||||
|
- Be specific but concise
|
||||||
|
- If multiple unrelated changes exist, consider splitting into separate commits
|
||||||
|
|
||||||
|
**Common hook failures and fixes:**
|
||||||
|
|
||||||
|
- **Linter errors**: Run the project's linter (e.g., `npm run lint` or `pnpm lint`) to see all issues, then fix them
|
||||||
|
- **Type checking errors**: Run type checker (e.g., `npx tsc --noEmit`) to identify type issues
|
||||||
|
- **Test failures**: Run tests (e.g., `npm test` or `pnpm test`) to identify failing tests and fix them
|
||||||
|
- **Format issues**: Run formatter (e.g., `npm run format` or `pnpm format`) to auto-fix formatting
|
||||||
67
.roo/rules-debug/cli.md
Normal file
67
.roo/rules-debug/cli.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# CLI Debugging with File-Based Logging
|
||||||
|
|
||||||
|
When debugging the CLI, `console.log` will break the TUI (Terminal User Interface). Use file-based logging to capture debug output without interfering with the application's display.
|
||||||
|
|
||||||
|
## File-Based Logging Strategy
|
||||||
|
|
||||||
|
1. **Write logs to a temporary file instead of console**:
|
||||||
|
|
||||||
|
- Create a log file at a known location, e.g., `/tmp/roo-cli-debug.log`
|
||||||
|
- Use `fs.appendFileSync()` to write timestamped log entries
|
||||||
|
- Example logging utility:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import fs from "fs"
|
||||||
|
const DEBUG_LOG = "/tmp/roo-cli-debug.log"
|
||||||
|
|
||||||
|
function debugLog(message: string, data?: unknown) {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const entry = data
|
||||||
|
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
|
||||||
|
: `[${timestamp}] ${message}\n`
|
||||||
|
fs.appendFileSync(DEBUG_LOG, entry)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clear the log file before each debugging session**:
|
||||||
|
- Run `echo "" > /tmp/roo-cli-debug.log` or use `fs.writeFileSync(DEBUG_LOG, "")` at app startup during debugging
|
||||||
|
|
||||||
|
## Iterative Debugging Workflow
|
||||||
|
|
||||||
|
Follow this feedback loop to systematically narrow down issues:
|
||||||
|
|
||||||
|
1. **Add targeted logging** at suspected problem areas based on your hypotheses
|
||||||
|
2. **Instruct the user** to reproduce the issue using the CLI normally
|
||||||
|
3. **Read the log file** after the user completes testing:
|
||||||
|
- Run `cat /tmp/roo-cli-debug.log` to retrieve the captured output
|
||||||
|
4. **Analyze the log output** to gather clues about:
|
||||||
|
- Execution flow and timing
|
||||||
|
- Variable values at key points
|
||||||
|
- Which code paths were taken
|
||||||
|
- Error conditions or unexpected states
|
||||||
|
5. **Refine your logging** based on findings—add more detail where needed, remove noise
|
||||||
|
6. **Ask the user to test again** with updated logging
|
||||||
|
7. **Repeat** until the root cause is identified
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Log entry/exit points of functions under investigation
|
||||||
|
- Include relevant variable values and state information
|
||||||
|
- Use descriptive prefixes to categorize logs: `[STATE]`, `[EVENT]`, `[ERROR]`, `[FLOW]`
|
||||||
|
- Log both the "happy path" and error handling branches
|
||||||
|
- When dealing with async operations, log before and after `await` statements
|
||||||
|
- For user interactions, log the received input and the resulting action
|
||||||
|
|
||||||
|
## Example Debug Session
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add logging to investigate a picker selection issue
|
||||||
|
debugLog("[FLOW] PickerSelect onSelect called", { selectedIndex, item })
|
||||||
|
debugLog("[STATE] Current selection state", { currentValue, isOpen })
|
||||||
|
|
||||||
|
// After async operation
|
||||||
|
const result = await fetchOptions()
|
||||||
|
debugLog("[FLOW] fetchOptions completed", { resultCount: result.length })
|
||||||
|
```
|
||||||
|
|
||||||
|
Then ask: "Please reproduce the issue by [specific steps]. When you're done, let me know and I'll analyze the debug logs."
|
||||||
116
apps/cli/CHANGELOG.md
Normal file
116
apps/cli/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to the `@roo-code/cli` package will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.0.45] - 2026-01-08
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Major Refactor**: Extracted ~1400 lines from [`App.tsx`](src/ui/App.tsx) into reusable hooks and utilities for better maintainability:
|
||||||
|
|
||||||
|
- [`useExtensionHost`](src/ui/hooks/useExtensionHost.ts) - Extension host connection and lifecycle management
|
||||||
|
- [`useMessageHandlers`](src/ui/hooks/useMessageHandlers.ts) - Message processing and state updates
|
||||||
|
- [`useTaskSubmit`](src/ui/hooks/useTaskSubmit.ts) - Task submission logic
|
||||||
|
- [`useGlobalInput`](src/ui/hooks/useGlobalInput.ts) - Global keyboard shortcut handling
|
||||||
|
- [`useFollowupCountdown`](src/ui/hooks/useFollowupCountdown.ts) - Auto-approval countdown logic
|
||||||
|
- [`useFocusManagement`](src/ui/hooks/useFocusManagement.ts) - Input focus state management
|
||||||
|
- [`usePickerHandlers`](src/ui/hooks/usePickerHandlers.ts) - Picker component event handling
|
||||||
|
- [`uiStateStore`](src/ui/stores/uiStateStore.ts) - UI-specific state (showExitHint, countdown, etc.)
|
||||||
|
- Tool data utilities ([`extractToolData`](src/ui/utils/toolDataUtils.ts), `formatToolOutput`, etc.)
|
||||||
|
- [`HorizontalLine`](src/ui/components/HorizontalLine.tsx) component
|
||||||
|
|
||||||
|
- **Performance Optimizations**:
|
||||||
|
|
||||||
|
- Added RAF-style scroll throttling to reduce state updates
|
||||||
|
- Stabilized `useExtensionHost` hook return values with `useCallback`/`useMemo`
|
||||||
|
- Added streaming message debouncing to batch rapid partial updates
|
||||||
|
- Added shallow array equality checks to prevent unnecessary re-renders
|
||||||
|
|
||||||
|
- Simplified [`ModeTool`](src/ui/components/tools/ModeTool.tsx) layout to horizontal with mode suffix
|
||||||
|
- Simplified logging by removing verbose debug output and adding first/last partial message logging pattern
|
||||||
|
- Updated Nerd Font icon codepoints in [`Icon`](src/ui/components/Icon.tsx) component
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `#` shortcut in help trigger for quick access to task history autocomplete
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a crash in message handling
|
||||||
|
- Added protected file warning in tool approval prompts
|
||||||
|
- Enabled `alwaysAllowWriteProtected` for non-interactive mode
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed unused `renderLogger.ts` utility file
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Updated extension-host tests to expect `[Tool Request]` format
|
||||||
|
- Updated Icon tests to expect single-char Nerd Font icons
|
||||||
|
|
||||||
|
## [0.0.44] - 2026-01-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tool Renderer Components**: Specialized renderers for displaying tool outputs with optimized formatting for each tool type. Each renderer provides a focused view of its data structure.
|
||||||
|
|
||||||
|
- [`FileReadTool`](src/ui/components/tools/FileReadTool.tsx) - Display file read operations with syntax highlighting
|
||||||
|
- [`FileWriteTool`](src/ui/components/tools/FileWriteTool.tsx) - Show file write/edit operations with diff views
|
||||||
|
- [`SearchTool`](src/ui/components/tools/SearchTool.tsx) - Render search results with context
|
||||||
|
- [`CommandTool`](src/ui/components/tools/CommandTool.tsx) - Display command execution with output
|
||||||
|
- [`BrowserTool`](src/ui/components/tools/BrowserTool.tsx) - Show browser automation actions
|
||||||
|
- [`ModeTool`](src/ui/components/tools/ModeTool.tsx) - Display mode switching operations
|
||||||
|
- [`CompletionTool`](src/ui/components/tools/CompletionTool.tsx) - Show task completion status
|
||||||
|
- [`GenericTool`](src/ui/components/tools/GenericTool.tsx) - Fallback renderer for other tools
|
||||||
|
|
||||||
|
- **History Trigger**: New `#` trigger for task history autocomplete with fuzzy search support. Type `#` at the start of a line to browse and resume previous tasks.
|
||||||
|
|
||||||
|
- [`HistoryTrigger.tsx`](src/ui/components/autocomplete/triggers/HistoryTrigger.tsx) - Trigger implementation with fuzzy filtering
|
||||||
|
- Shows task status, mode, and relative timestamps
|
||||||
|
- Supports keyboard navigation for quick task selection
|
||||||
|
|
||||||
|
- **Release Confirmation Prompt**: The release script now prompts for confirmation before creating a release.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Task history picker selection and navigation issues
|
||||||
|
- Mode switcher keyboard handling bug
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reorganized test files into `__tests__` directories for better project structure
|
||||||
|
- Refactored utility modules into dedicated `utils/` directory
|
||||||
|
|
||||||
|
## [0.0.43] - 2026-01-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Toast Notification System**: New toast notifications for user feedback with support for info, success, warning, and error types. Toasts auto-dismiss after a configurable duration and are managed via Zustand store.
|
||||||
|
|
||||||
|
- New [`ToastDisplay`](src/ui/components/ToastDisplay.tsx) component for rendering toast messages
|
||||||
|
- New [`useToast`](src/ui/hooks/useToast.ts) hook for managing toast state and displaying notifications
|
||||||
|
|
||||||
|
- **Global Input Sequences Registry**: Centralized system for handling keyboard shortcuts at the application level, preventing conflicts with input components.
|
||||||
|
|
||||||
|
- New [`globalInputSequences.ts`](src/ui/utils/globalInputSequences.ts) utility module
|
||||||
|
- Support for Kitty keyboard protocol (CSI u encoding) for better terminal compatibility
|
||||||
|
- Built-in sequences for `Ctrl+C` (exit) and `Ctrl+M` (mode cycling)
|
||||||
|
|
||||||
|
- **Local Tarball Installation**: The install script now supports installing from a local tarball via the `ROO_LOCAL_TARBALL` environment variable, useful for offline installation or testing pre-release builds.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **MultilineTextInput**: Updated to respect global input sequences, preventing the component from consuming shortcuts meant for application-level handling.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Added comprehensive tests for the toast notification system
|
||||||
|
- Added tests for global input sequence matching
|
||||||
|
|
||||||
|
## [0.0.42] - 2025-01-07
|
||||||
|
|
||||||
|
The cli is alive!
|
||||||
|
|
@ -71,7 +71,13 @@ By default, the CLI prompts for approval before executing actions:
|
||||||
```bash
|
```bash
|
||||||
export OPENROUTER_API_KEY=sk-or-v1-...
|
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:
|
In interactive mode:
|
||||||
|
|
@ -86,30 +92,33 @@ In interactive mode:
|
||||||
For automation and scripts, use `-y` to auto-approve all actions:
|
For automation and scripts, use `-y` to auto-approve all actions:
|
||||||
|
|
||||||
```bash
|
```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:
|
In non-interactive mode:
|
||||||
|
|
||||||
- Tool, command, browser, and MCP actions are auto-approved
|
- Tool, command, browser, and MCP actions are auto-approved
|
||||||
- Followup questions show a 10-second timeout, then auto-select the first suggestion
|
- Followup questions show a 60-second timeout, then auto-select the first suggestion
|
||||||
- Typing any key cancels the timeout and allows manual input
|
- Typing any key cancels the timeout and allows manual input
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
|
| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- |
|
||||||
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
|
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
|
||||||
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
|
||||||
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
|
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
||||||
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
|
||||||
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
|
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
||||||
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
|
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
|
||||||
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
|
||||||
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
|
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
||||||
| `-m, --model <model>` | Model to use | Provider default |
|
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
|
||||||
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
| `-m, --model <model>` | Model to use | `anthropic/claude-sonnet-4.5` |
|
||||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
|
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||||
|
| `-r, --reasoning-effort <effort>` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` |
|
||||||
|
| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` |
|
||||||
|
| `--no-tui` | Disable TUI, use plain text output | `false` |
|
||||||
|
|
||||||
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
|
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
|
||||||
|
|
||||||
|
|
@ -123,9 +132,7 @@ The CLI will look for API keys in environment variables if not provided via `--a
|
||||||
| openai | `OPENAI_API_KEY` |
|
| openai | `OPENAI_API_KEY` |
|
||||||
| openrouter | `OPENROUTER_API_KEY` |
|
| openrouter | `OPENROUTER_API_KEY` |
|
||||||
| google/gemini | `GOOGLE_API_KEY` |
|
| google/gemini | `GOOGLE_API_KEY` |
|
||||||
| mistral | `MISTRAL_API_KEY` |
|
| ... | ... |
|
||||||
| deepseek | `DEEPSEEK_API_KEY` |
|
|
||||||
| bedrock | `AWS_ACCESS_KEY_ID` |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -166,12 +173,6 @@ The CLI will look for API keys in environment variables if not provided via `--a
|
||||||
- CLI → Extension: `emit("webviewMessage", {...})`
|
- CLI → Extension: `emit("webviewMessage", {...})`
|
||||||
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
|
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
|
||||||
|
|
||||||
## Current Limitations
|
|
||||||
|
|
||||||
- **No TUI**: Output is plain text (no React/Ink UI yet)
|
|
||||||
- **No configuration file**: Settings are passed via command line flags
|
|
||||||
- **No persistence**: Each run is a fresh session
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -190,42 +191,17 @@ pnpm lint
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
To create a new release, run the release script from the monorepo root:
|
To create a new release, execute the /cli-release slash command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Release using version from package.json
|
roo ~/Documents/Roo-Code -P "/cli-release" -y
|
||||||
./apps/cli/scripts/release.sh
|
|
||||||
|
|
||||||
# Release with a specific version
|
|
||||||
./apps/cli/scripts/release.sh 0.1.0
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will:
|
The workflow will:
|
||||||
|
|
||||||
1. Build the extension and CLI
|
1. Bump the version
|
||||||
2. Create a platform-specific tarball (for your current OS/architecture)
|
2. Update the CHANGELOG
|
||||||
3. Create a GitHub release with the tarball attached
|
3. Build the extension and CLI
|
||||||
|
4. Create a platform-specific tarball (for your current OS/architecture)
|
||||||
**Prerequisites:**
|
5. Test the install script
|
||||||
|
6. Create a GitHub release with the tarball attached
|
||||||
- GitHub CLI (`gh`) installed and authenticated (`gh auth login`)
|
|
||||||
- pnpm installed
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Extension bundle not found
|
|
||||||
|
|
||||||
Make sure you've built the main extension first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src
|
|
||||||
pnpm bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
### Module resolution errors
|
|
||||||
|
|
||||||
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
|
|
||||||
|
|
||||||
### "vscode" module not found
|
|
||||||
|
|
||||||
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
|
# Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
|
||||||
#
|
#
|
||||||
# Environment variables:
|
# Environment variables:
|
||||||
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
|
# ROO_INSTALL_DIR - Installation directory (default: ~/.roo/cli)
|
||||||
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
|
# ROO_BIN_DIR - Binary symlink directory (default: ~/.local/bin)
|
||||||
# ROO_VERSION - Specific version to install (default: latest)
|
# ROO_VERSION - Specific version to install (default: latest)
|
||||||
|
# ROO_LOCAL_TARBALL - Path to local tarball to install (skips download)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|
@ -83,6 +84,13 @@ detect_platform() {
|
||||||
|
|
||||||
# Get latest release version or use specified version
|
# Get latest release version or use specified version
|
||||||
get_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
|
if [ -n "$ROO_VERSION" ]; then
|
||||||
VERSION="$ROO_VERSION"
|
VERSION="$ROO_VERSION"
|
||||||
info "Using specified version: $VERSION"
|
info "Using specified version: $VERSION"
|
||||||
|
|
@ -113,27 +121,37 @@ get_version() {
|
||||||
# Download and extract
|
# Download and extract
|
||||||
download_and_install() {
|
download_and_install() {
|
||||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||||
URL="https://github.com/$REPO/releases/download/cli-v${VERSION}/${TARBALL}"
|
|
||||||
|
|
||||||
info "Downloading from $URL..."
|
|
||||||
|
|
||||||
# Create temp directory
|
# Create temp directory
|
||||||
TMP_DIR=$(mktemp -d)
|
TMP_DIR=$(mktemp -d)
|
||||||
trap "rm -rf $TMP_DIR" EXIT
|
trap "rm -rf $TMP_DIR" EXIT
|
||||||
|
|
||||||
# Download with progress indicator
|
# Use local tarball if provided, otherwise download
|
||||||
HTTP_CODE=$(curl -fsSL -w "%{http_code}" "$URL" -o "$TMP_DIR/$TARBALL" 2>/dev/null) || {
|
if [ -n "$ROO_LOCAL_TARBALL" ]; then
|
||||||
if [ "$HTTP_CODE" = "404" ]; then
|
if [ ! -f "$ROO_LOCAL_TARBALL" ]; then
|
||||||
error "Release not found for platform $PLATFORM version $VERSION.
|
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"
|
Available at: https://github.com/$REPO/releases"
|
||||||
fi
|
fi
|
||||||
error "Download failed. HTTP code: $HTTP_CODE"
|
error "Download failed. HTTP code: $HTTP_CODE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Verify we got something
|
# Verify we got something
|
||||||
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
|
if [ ! -s "$TMP_DIR/$TARBALL" ]; then
|
||||||
error "Downloaded file is empty. Please try again."
|
error "Downloaded file is empty. Please try again."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove old installation if exists
|
# Remove old installation if exists
|
||||||
|
|
@ -260,7 +278,7 @@ print_success() {
|
||||||
echo ""
|
echo ""
|
||||||
echo " ${BOLD}Example:${NC}"
|
echo " ${BOLD}Example:${NC}"
|
||||||
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
|
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 ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@roo-code/cli",
|
"name": "@roo-code/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.0.45",
|
||||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -14,22 +14,31 @@
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"release": "scripts/release.sh",
|
||||||
"clean": "rimraf dist .turbo"
|
"clean": "rimraf dist .turbo"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inkjs/ui": "^2.0.0",
|
||||||
|
"@roo-code/core": "workspace:^",
|
||||||
"@roo-code/types": "workspace:^",
|
"@roo-code/types": "workspace:^",
|
||||||
"@roo-code/vscode-shim": "workspace:^",
|
"@roo-code/vscode-shim": "workspace:^",
|
||||||
"@vscode/ripgrep": "^1.15.9",
|
"@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": {
|
"devDependencies": {
|
||||||
"@roo-code/config-eslint": "workspace:^",
|
"@roo-code/config-eslint": "workspace:^",
|
||||||
"@roo-code/config-typescript": "workspace:^",
|
"@roo-code/config-typescript": "workspace:^",
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "^24.1.0",
|
||||||
|
"@types/react": "^19.1.6",
|
||||||
|
"ink-testing-library": "^4.0.0",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"typescript": "5.8.3",
|
|
||||||
"vitest": "^3.2.3"
|
"vitest": "^3.2.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ detect_platform() {
|
||||||
|
|
||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
check_prerequisites() {
|
check_prerequisites() {
|
||||||
step "1/7" "Checking prerequisites..."
|
step "1/8" "Checking prerequisites..."
|
||||||
|
|
||||||
if ! command -v gh &> /dev/null; then
|
if ! command -v gh &> /dev/null; then
|
||||||
error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
|
error "GitHub CLI (gh) is not installed. Install it with: brew install gh"
|
||||||
|
|
@ -98,13 +98,71 @@ get_version() {
|
||||||
info "Version: $VERSION (tag: $TAG)"
|
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 everything
|
||||||
build() {
|
build() {
|
||||||
step "2/7" "Building extension bundle..."
|
step "2/8" "Building extension bundle..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
pnpm bundle
|
pnpm bundle
|
||||||
|
|
||||||
step "3/7" "Building CLI..."
|
step "3/8" "Building CLI..."
|
||||||
pnpm --filter @roo-code/cli build
|
pnpm --filter @roo-code/cli build
|
||||||
|
|
||||||
info "Build complete"
|
info "Build complete"
|
||||||
|
|
@ -112,7 +170,7 @@ build() {
|
||||||
|
|
||||||
# Create release tarball
|
# Create release tarball
|
||||||
create_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}"
|
RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}"
|
||||||
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
TARBALL="roo-cli-${PLATFORM}.tar.gz"
|
||||||
|
|
@ -130,7 +188,7 @@ create_tarball() {
|
||||||
info "Copying CLI files..."
|
info "Copying CLI files..."
|
||||||
cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
|
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..."
|
info "Creating package.json..."
|
||||||
node -e "
|
node -e "
|
||||||
const pkg = require('$CLI_DIR/package.json');
|
const pkg = require('$CLI_DIR/package.json');
|
||||||
|
|
@ -139,7 +197,12 @@ create_tarball() {
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
type: 'module',
|
type: 'module',
|
||||||
dependencies: {
|
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));
|
console.log(JSON.stringify(newPkg, null, 2));
|
||||||
|
|
@ -197,6 +260,9 @@ WRAPPER_EOF
|
||||||
# Create version file
|
# Create version file
|
||||||
echo "$VERSION" > "$RELEASE_DIR/VERSION"
|
echo "$VERSION" > "$RELEASE_DIR/VERSION"
|
||||||
|
|
||||||
|
# Create empty .env file to suppress dotenvx warnings
|
||||||
|
touch "$RELEASE_DIR/.env"
|
||||||
|
|
||||||
# Create tarball
|
# Create tarball
|
||||||
info "Creating tarball..."
|
info "Creating tarball..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
@ -211,9 +277,91 @@ WRAPPER_EOF
|
||||||
info "Created: $TARBALL ($TARBALL_SIZE)"
|
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
|
||||||
create_checksum() {
|
create_checksum() {
|
||||||
step "5/7" "Creating checksum..."
|
step "6/8" "Creating checksum..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
if command -v sha256sum &> /dev/null; then
|
if command -v sha256sum &> /dev/null; then
|
||||||
|
|
@ -230,7 +378,7 @@ create_checksum() {
|
||||||
|
|
||||||
# Check if release already exists
|
# Check if release already exists
|
||||||
check_existing_release() {
|
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
|
if gh release view "$TAG" &> /dev/null; then
|
||||||
warn "Release $TAG already exists"
|
warn "Release $TAG already exists"
|
||||||
|
|
@ -250,11 +398,45 @@ check_existing_release() {
|
||||||
|
|
||||||
# Create GitHub release
|
# Create GitHub release
|
||||||
create_release() {
|
create_release() {
|
||||||
step "7/7" "Creating GitHub release..."
|
step "8/8" "Creating GitHub release..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Get the current commit SHA for the release target
|
||||||
|
COMMIT_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
# Verify the commit exists on GitHub before attempting to create the release
|
||||||
|
# This prevents the "Release.target_commitish is invalid" error
|
||||||
|
info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..."
|
||||||
|
git fetch origin 2>/dev/null || true
|
||||||
|
if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then
|
||||||
|
warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub"
|
||||||
|
echo ""
|
||||||
|
echo "The release script needs to create a release at your current commit,"
|
||||||
|
echo "but this commit hasn't been pushed to GitHub yet."
|
||||||
|
echo ""
|
||||||
|
read -p "Push current branch to origin now? [Y/n] " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||||
|
info "Pushing to origin..."
|
||||||
|
git push origin HEAD || error "Failed to push to origin. Please push manually and try again."
|
||||||
|
else
|
||||||
|
error "Aborted. Please push your commits to GitHub and try again."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
info "Commit verified on GitHub"
|
||||||
|
|
||||||
|
# Build the What's New section from changelog content
|
||||||
|
WHATS_NEW_SECTION=""
|
||||||
|
if [ -n "$CHANGELOG_CONTENT" ]; then
|
||||||
|
WHATS_NEW_SECTION="## What's New
|
||||||
|
|
||||||
|
$CHANGELOG_CONTENT
|
||||||
|
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
RELEASE_NOTES=$(cat << EOF
|
RELEASE_NOTES=$(cat << EOF
|
||||||
## Installation
|
${WHATS_NEW_SECTION}## Installation
|
||||||
|
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh
|
||||||
|
|
@ -277,7 +459,7 @@ ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo
|
||||||
export OPENROUTER_API_KEY=sk-or-v1-...
|
export OPENROUTER_API_KEY=sk-or-v1-...
|
||||||
|
|
||||||
# Run a task
|
# Run a task
|
||||||
roo "What is this project?" --workspace ~/my-project
|
roo "What is this project?" ~/my-project
|
||||||
|
|
||||||
# See all options
|
# See all options
|
||||||
roo --help
|
roo --help
|
||||||
|
|
@ -298,8 +480,6 @@ $(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A")
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the current commit SHA for the release target
|
|
||||||
COMMIT_SHA=$(git rev-parse HEAD)
|
|
||||||
info "Creating release at commit: ${COMMIT_SHA:0:8}"
|
info "Creating release at commit: ${COMMIT_SHA:0:8}"
|
||||||
|
|
||||||
# Create release (gh will create the tag automatically)
|
# Create release (gh will create the tag automatically)
|
||||||
|
|
@ -351,8 +531,10 @@ main() {
|
||||||
detect_platform
|
detect_platform
|
||||||
check_prerequisites
|
check_prerequisites
|
||||||
get_version "$1"
|
get_version "$1"
|
||||||
|
get_changelog_content
|
||||||
build
|
build
|
||||||
create_tarball
|
create_tarball
|
||||||
|
verify_local_install
|
||||||
create_checksum
|
create_checksum
|
||||||
check_existing_release
|
check_existing_release
|
||||||
create_release
|
create_release
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts
|
// 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 { 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", () => ({
|
vi.mock("@roo-code/vscode-shim", () => ({
|
||||||
createVSCodeAPI: vi.fn(() => ({
|
createVSCodeAPI: vi.fn(() => ({
|
||||||
context: { extensionPath: "/test/extension" },
|
context: { extensionPath: "/test/extension" },
|
||||||
})),
|
})),
|
||||||
|
setRuntimeConfigValues: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -369,15 +375,15 @@ describe("ExtensionHost", () => {
|
||||||
const emitSpy = vi.spyOn(host, "emit")
|
const emitSpy = vi.spyOn(host, "emit")
|
||||||
|
|
||||||
// Queue messages before ready
|
// Queue messages before ready
|
||||||
host.sendToExtension({ type: "test1" })
|
host.sendToExtension({ type: "requestModes" })
|
||||||
host.sendToExtension({ type: "test2" })
|
host.sendToExtension({ type: "requestCommands" })
|
||||||
|
|
||||||
// Mark ready (should flush)
|
// Mark ready (should flush)
|
||||||
host.markWebviewReady()
|
host.markWebviewReady()
|
||||||
|
|
||||||
// Check that webviewMessage events were emitted for pending messages
|
// Check that webviewMessage events were emitted for pending messages
|
||||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" })
|
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" })
|
||||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" })
|
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -385,7 +391,7 @@ describe("ExtensionHost", () => {
|
||||||
describe("sendToExtension", () => {
|
describe("sendToExtension", () => {
|
||||||
it("should queue message when webview not ready", () => {
|
it("should queue message when webview not ready", () => {
|
||||||
const host = createTestHost()
|
const host = createTestHost()
|
||||||
const message = { type: "test" }
|
const message: WebviewMessage = { type: "requestModes" }
|
||||||
|
|
||||||
host.sendToExtension(message)
|
host.sendToExtension(message)
|
||||||
|
|
||||||
|
|
@ -396,7 +402,7 @@ describe("ExtensionHost", () => {
|
||||||
it("should emit webviewMessage event when webview is ready", () => {
|
it("should emit webviewMessage event when webview is ready", () => {
|
||||||
const host = createTestHost()
|
const host = createTestHost()
|
||||||
const emitSpy = vi.spyOn(host, "emit")
|
const emitSpy = vi.spyOn(host, "emit")
|
||||||
const message = { type: "test" }
|
const message: WebviewMessage = { type: "requestModes" }
|
||||||
|
|
||||||
host.markWebviewReady()
|
host.markWebviewReady()
|
||||||
host.sendToExtension(message)
|
host.sendToExtension(message)
|
||||||
|
|
@ -408,7 +414,7 @@ describe("ExtensionHost", () => {
|
||||||
const host = createTestHost()
|
const host = createTestHost()
|
||||||
|
|
||||||
host.markWebviewReady()
|
host.markWebviewReady()
|
||||||
host.sendToExtension({ type: "test" })
|
host.sendToExtension({ type: "requestModes" })
|
||||||
|
|
||||||
const pending = getPrivate<unknown[]>(host, "pendingMessages")
|
const pending = getPrivate<unknown[]>(host, "pendingMessages")
|
||||||
expect(pending).toHaveLength(0)
|
expect(pending).toHaveLength(0)
|
||||||
|
|
@ -433,24 +439,6 @@ describe("ExtensionHost", () => {
|
||||||
|
|
||||||
expect(handleMsgUpdatedSpy).toHaveBeenCalled()
|
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", () => {
|
describe("handleSayMessage", () => {
|
||||||
|
|
@ -581,7 +569,7 @@ describe("ExtensionHost", () => {
|
||||||
|
|
||||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||||
|
|
||||||
expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file")
|
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] write_file")
|
||||||
expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt")
|
expect(outputSpy).toHaveBeenCalledWith(" path: /test/file.txt")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -594,7 +582,7 @@ describe("ExtensionHost", () => {
|
||||||
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
callPrivate(host, "handleAskMessage", 123, "tool", toolInfo, false)
|
||||||
|
|
||||||
// Content is now shown (all tool parameters are displayed)
|
// Content is now shown (all tool parameters are displayed)
|
||||||
expect(outputSpy).toHaveBeenCalledWith("\n[tool] write_file")
|
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] write_file")
|
||||||
expect(outputSpy).toHaveBeenCalledWith(
|
expect(outputSpy).toHaveBeenCalledWith(
|
||||||
" content: This is the content that will be written to the file. It might be long.",
|
" content: This is the content that will be written to the file. It might be long.",
|
||||||
)
|
)
|
||||||
|
|
@ -603,7 +591,7 @@ describe("ExtensionHost", () => {
|
||||||
it("should handle tool type with invalid JSON in non-interactive mode", () => {
|
it("should handle tool type with invalid JSON in non-interactive mode", () => {
|
||||||
callPrivate(host, "handleAskMessage", 123, "tool", "not json", false)
|
callPrivate(host, "handleAskMessage", 123, "tool", "not json", false)
|
||||||
|
|
||||||
expect(outputSpy).toHaveBeenCalledWith("\n[tool]", "not json")
|
expect(outputSpy).toHaveBeenCalledWith("\n[Tool Request] unknown")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not display duplicate messages for same ts", () => {
|
it("should not display duplicate messages for same ts", () => {
|
||||||
|
|
@ -810,7 +798,7 @@ describe("ExtensionHost", () => {
|
||||||
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
|
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
|
||||||
|
|
||||||
// Should show prompt with timeout hint
|
// 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")
|
await expect(promise).rejects.toThrow("Test error")
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("should timeout after configured duration", async () => {
|
describe("handleStateMessage - mode tracking", () => {
|
||||||
const host = createTestHost()
|
let host: ExtensionHost
|
||||||
|
|
||||||
// Use fake timers for this test
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
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)
|
it("should track current mode when state updates with a mode", () => {
|
||||||
vi.advanceTimersByTime(10 * 60 * 1000 + 1)
|
// 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
5
apps/cli/src/constants.ts
Normal file
5
apps/cli/src/constants.ts
Normal 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
|
|
@ -4,8 +4,10 @@
|
||||||
|
|
||||||
import { Command } from "commander"
|
import { Command } from "commander"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { createRequire } from "module"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
import { createElement } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
|
|
@ -16,7 +18,7 @@ import {
|
||||||
import { setLogger } from "@roo-code/vscode-shim"
|
import { setLogger } from "@roo-code/vscode-shim"
|
||||||
|
|
||||||
import { ExtensionHost } from "./extension-host.js"
|
import { ExtensionHost } from "./extension-host.js"
|
||||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
|
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils/extensionHostUtils.js"
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
mode: "code",
|
mode: "code",
|
||||||
|
|
@ -28,13 +30,20 @@ const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
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()
|
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
|
program
|
||||||
.argument("<prompt>", "The prompt/task to execute")
|
.argument("[workspace]", "Workspace path to operate in", process.cwd())
|
||||||
.option("-w, --workspace <path>", "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("-e, --extension <path>", "Path to the extension bundle directory")
|
||||||
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
|
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
|
||||||
.option("-d, --debug", "Enable debug output (includes detailed debug information)", 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)",
|
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||||
DEFAULTS.reasoningEffort,
|
DEFAULTS.reasoningEffort,
|
||||||
)
|
)
|
||||||
|
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
|
||||||
|
.option("--no-tui", "Disable TUI, use plain text output")
|
||||||
.action(
|
.action(
|
||||||
async (
|
async (
|
||||||
prompt: string,
|
workspaceArg: string,
|
||||||
options: {
|
options: {
|
||||||
workspace: string
|
prompt?: string
|
||||||
extension?: string
|
extension?: string
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
debug: boolean
|
debug: boolean
|
||||||
|
|
@ -64,6 +75,8 @@ program
|
||||||
model?: string
|
model?: string
|
||||||
mode?: string
|
mode?: string
|
||||||
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
||||||
|
ephemeral: boolean
|
||||||
|
tui: boolean
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// Default is quiet mode - suppress VSCode shim logs unless verbose
|
// Default is quiet mode - suppress VSCode shim logs unless verbose
|
||||||
|
|
@ -79,7 +92,7 @@ program
|
||||||
|
|
||||||
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
||||||
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
||||||
const workspacePath = path.resolve(options.workspace)
|
const workspacePath = path.resolve(workspaceArg)
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -106,57 +119,147 @@ program
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[CLI] Mode: ${options.mode || "default"}`)
|
// TUI is enabled by default, disabled with --no-tui
|
||||||
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
|
// TUI requires raw mode support (proper TTY for stdin and stdout)
|
||||||
console.log(`[CLI] Provider: ${options.provider}`)
|
const canUseTui = process.stdin.isTTY && process.stdout.isTTY
|
||||||
console.log(`[CLI] Model: ${options.model || "default"}`)
|
const useTui = options.tui && canUseTui
|
||||||
console.log(`[CLI] Workspace: ${workspacePath}`)
|
|
||||||
|
|
||||||
const host = new ExtensionHost({
|
if (options.tui && !canUseTui) {
|
||||||
mode: options.mode || DEFAULTS.mode,
|
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
|
||||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
}
|
||||||
apiProvider: options.provider,
|
|
||||||
apiKey,
|
|
||||||
model: options.model || DEFAULTS.model,
|
|
||||||
workspacePath,
|
|
||||||
extensionPath: path.resolve(extensionPath),
|
|
||||||
verbose: options.debug,
|
|
||||||
quiet: !options.verbose && !options.debug,
|
|
||||||
nonInteractive: options.yes,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle SIGINT (Ctrl+C)
|
// In plain text mode, prompt is required
|
||||||
process.on("SIGINT", async () => {
|
if (!useTui && !options.prompt) {
|
||||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
console.error("[CLI] Error: prompt is required in plain text mode")
|
||||||
await host.dispose()
|
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
|
||||||
process.exit(130)
|
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
|
||||||
})
|
|
||||||
|
|
||||||
// Handle SIGTERM
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
|
||||||
await host.dispose()
|
|
||||||
process.exit(143)
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await host.activate()
|
|
||||||
await host.runTask(prompt)
|
|
||||||
await host.dispose()
|
|
||||||
|
|
||||||
if (options.exitOnComplete) {
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
|
||||||
|
|
||||||
if (options.debug && error instanceof Error) {
|
|
||||||
console.error(error.stack)
|
|
||||||
}
|
|
||||||
|
|
||||||
await host.dispose()
|
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useTui) {
|
||||||
|
// TUI Mode - render Ink application
|
||||||
|
try {
|
||||||
|
const { render } = await import("ink")
|
||||||
|
const { App } = await import("./ui/App.js")
|
||||||
|
|
||||||
|
// Create extension host factory for dependency injection
|
||||||
|
const createExtensionHost = (opts: {
|
||||||
|
mode: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
apiProvider: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
workspacePath: string
|
||||||
|
extensionPath: string
|
||||||
|
verbose: boolean
|
||||||
|
quiet: boolean
|
||||||
|
nonInteractive: boolean
|
||||||
|
disableOutput: boolean
|
||||||
|
ephemeral?: boolean
|
||||||
|
}) => {
|
||||||
|
return new ExtensionHost({
|
||||||
|
mode: opts.mode,
|
||||||
|
reasoningEffort:
|
||||||
|
opts.reasoningEffort === "unspecified"
|
||||||
|
? undefined
|
||||||
|
: (opts.reasoningEffort as ReasoningEffortExtended | "disabled" | undefined),
|
||||||
|
apiProvider: opts.apiProvider as ProviderName,
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
model: opts.model,
|
||||||
|
workspacePath: opts.workspacePath,
|
||||||
|
extensionPath: opts.extensionPath,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
quiet: opts.quiet,
|
||||||
|
nonInteractive: opts.nonInteractive,
|
||||||
|
disableOutput: opts.disableOutput,
|
||||||
|
ephemeral: opts.ephemeral,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(App, {
|
||||||
|
initialPrompt: options.prompt || "", // Empty string if no prompt - user will type in TUI
|
||||||
|
workspacePath: workspacePath,
|
||||||
|
extensionPath: path.resolve(extensionPath),
|
||||||
|
apiProvider: options.provider,
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: options.model || DEFAULTS.model,
|
||||||
|
mode: options.mode || DEFAULTS.mode,
|
||||||
|
nonInteractive: options.yes,
|
||||||
|
verbose: options.verbose,
|
||||||
|
debug: options.debug,
|
||||||
|
exitOnComplete: options.exitOnComplete,
|
||||||
|
reasoningEffort: options.reasoningEffort,
|
||||||
|
ephemeral: options.ephemeral,
|
||||||
|
createExtensionHost: createExtensionHost,
|
||||||
|
version: packageJson.version,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
|
||||||
|
if (options.debug && error instanceof Error) {
|
||||||
|
console.error(error.stack)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Plain text mode (existing behavior)
|
||||||
|
console.log(`[CLI] Mode: ${options.mode || "default"}`)
|
||||||
|
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
|
||||||
|
console.log(`[CLI] Provider: ${options.provider}`)
|
||||||
|
console.log(`[CLI] Model: ${options.model || "default"}`)
|
||||||
|
console.log(`[CLI] Workspace: ${workspacePath}`)
|
||||||
|
|
||||||
|
const host = new ExtensionHost({
|
||||||
|
mode: options.mode || DEFAULTS.mode,
|
||||||
|
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||||
|
apiProvider: options.provider,
|
||||||
|
apiKey,
|
||||||
|
model: options.model || DEFAULTS.model,
|
||||||
|
workspacePath,
|
||||||
|
extensionPath: path.resolve(extensionPath),
|
||||||
|
verbose: options.debug,
|
||||||
|
quiet: !options.verbose && !options.debug,
|
||||||
|
nonInteractive: options.yes,
|
||||||
|
ephemeral: options.ephemeral,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle SIGINT (Ctrl+C)
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||||
|
await host.dispose()
|
||||||
|
process.exit(130)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle SIGTERM
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||||
|
await host.dispose()
|
||||||
|
process.exit(143)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await host.activate()
|
||||||
|
await host.runTask(options.prompt!) // prompt is guaranteed non-null in plain text mode
|
||||||
|
await host.dispose()
|
||||||
|
|
||||||
|
if (options.exitOnComplete) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||||
|
|
||||||
|
if (options.debug && error instanceof Error) {
|
||||||
|
console.error(error.stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.dispose()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
644
apps/cli/src/ui/App.tsx
Normal file
644
apps/cli/src/ui/App.tsx
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { Box, Text, useApp, useInput } from "ink"
|
||||||
|
import { Select } from "@inkjs/ui"
|
||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||||
|
import type { WebviewMessage } from "@roo-code/types"
|
||||||
|
|
||||||
|
import { getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js"
|
||||||
|
import { arePathsEqual } from "../utils/pathUtils.js"
|
||||||
|
import { getContextWindow } from "../utils/getContextWindow.js"
|
||||||
|
import type { AppProps } from "./types.js"
|
||||||
|
import * as theme from "./theme.js"
|
||||||
|
|
||||||
|
import { useCLIStore } from "./store.js"
|
||||||
|
import { useUIStateStore } from "./stores/uiStateStore.js"
|
||||||
|
|
||||||
|
// Import extracted hooks
|
||||||
|
import {
|
||||||
|
TerminalSizeProvider,
|
||||||
|
useTerminalSize,
|
||||||
|
useToast,
|
||||||
|
useExtensionHost,
|
||||||
|
useMessageHandlers,
|
||||||
|
useTaskSubmit,
|
||||||
|
useGlobalInput,
|
||||||
|
useFollowupCountdown,
|
||||||
|
useFocusManagement,
|
||||||
|
usePickerHandlers,
|
||||||
|
} from "./hooks/index.js"
|
||||||
|
|
||||||
|
// Import extracted utilities
|
||||||
|
import { getView } from "./utils/index.js"
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import Header from "./components/Header.js"
|
||||||
|
import ChatHistoryItem from "./components/ChatHistoryItem.js"
|
||||||
|
import LoadingText from "./components/LoadingText.js"
|
||||||
|
import ToastDisplay from "./components/ToastDisplay.js"
|
||||||
|
import TodoDisplay from "./components/TodoDisplay.js"
|
||||||
|
import { HorizontalLine } from "./components/HorizontalLine.js"
|
||||||
|
import {
|
||||||
|
type AutocompleteInputHandle,
|
||||||
|
type AutocompleteTrigger,
|
||||||
|
type FileResult,
|
||||||
|
type SlashCommandResult,
|
||||||
|
AutocompleteInput,
|
||||||
|
PickerSelect,
|
||||||
|
createFileTrigger,
|
||||||
|
createSlashCommandTrigger,
|
||||||
|
createModeTrigger,
|
||||||
|
createHelpTrigger,
|
||||||
|
createHistoryTrigger,
|
||||||
|
toFileResult,
|
||||||
|
toSlashCommandResult,
|
||||||
|
toModeResult,
|
||||||
|
toHistoryResult,
|
||||||
|
} from "./components/autocomplete/index.js"
|
||||||
|
import { ScrollArea, useScrollToBottom } from "./components/ScrollArea.js"
|
||||||
|
import ScrollIndicator from "./components/ScrollIndicator.js"
|
||||||
|
|
||||||
|
const PICKER_HEIGHT = 10
|
||||||
|
|
||||||
|
interface ExtensionHostInterface {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
on(event: string, handler: (...args: any[]) => void): void
|
||||||
|
activate(): Promise<void>
|
||||||
|
runTask(prompt: string): Promise<void>
|
||||||
|
sendToExtension(message: WebviewMessage): void
|
||||||
|
dispose(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionHostFactoryOptions {
|
||||||
|
mode: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
apiProvider: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
workspacePath: string
|
||||||
|
extensionPath: string
|
||||||
|
verbose: boolean
|
||||||
|
quiet: boolean
|
||||||
|
nonInteractive: boolean
|
||||||
|
disableOutput: boolean
|
||||||
|
ephemeral?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TUIAppProps extends AppProps {
|
||||||
|
/** Extension host factory - allows dependency injection for testing. */
|
||||||
|
createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner App component that uses the terminal size context
|
||||||
|
*/
|
||||||
|
function AppInner({
|
||||||
|
initialPrompt,
|
||||||
|
workspacePath,
|
||||||
|
extensionPath,
|
||||||
|
apiProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
mode,
|
||||||
|
nonInteractive,
|
||||||
|
verbose,
|
||||||
|
debug,
|
||||||
|
exitOnComplete,
|
||||||
|
reasoningEffort,
|
||||||
|
ephemeral,
|
||||||
|
createExtensionHost,
|
||||||
|
version,
|
||||||
|
}: TUIAppProps) {
|
||||||
|
const { exit } = useApp()
|
||||||
|
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
pendingAsk,
|
||||||
|
isLoading,
|
||||||
|
isComplete,
|
||||||
|
hasStartedTask: _hasStartedTask,
|
||||||
|
error,
|
||||||
|
fileSearchResults,
|
||||||
|
allSlashCommands,
|
||||||
|
availableModes,
|
||||||
|
taskHistory,
|
||||||
|
currentMode,
|
||||||
|
tokenUsage,
|
||||||
|
routerModels,
|
||||||
|
apiConfiguration,
|
||||||
|
currentTodos,
|
||||||
|
} = useCLIStore()
|
||||||
|
|
||||||
|
// Access UI state from the UI store
|
||||||
|
const {
|
||||||
|
showExitHint,
|
||||||
|
countdownSeconds,
|
||||||
|
showCustomInput,
|
||||||
|
isTransitioningToCustomInput,
|
||||||
|
showTodoViewer,
|
||||||
|
pickerState,
|
||||||
|
setIsTransitioningToCustomInput,
|
||||||
|
} = useUIStateStore()
|
||||||
|
|
||||||
|
// Compute context window from router models and API configuration
|
||||||
|
const contextWindow = useMemo(() => {
|
||||||
|
return getContextWindow(routerModels, apiConfiguration)
|
||||||
|
}, [routerModels, apiConfiguration])
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const autocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const followupAutocompleteRef = useRef<AutocompleteInputHandle<any>>(null)
|
||||||
|
|
||||||
|
// Stable refs for autocomplete data - prevents useMemo from recreating triggers on every data change
|
||||||
|
const fileSearchResultsRef = useRef(fileSearchResults)
|
||||||
|
const allSlashCommandsRef = useRef(allSlashCommands)
|
||||||
|
const availableModesRef = useRef(availableModes)
|
||||||
|
const taskHistoryRef = useRef(taskHistory)
|
||||||
|
|
||||||
|
// Keep refs in sync with current state
|
||||||
|
useEffect(() => {
|
||||||
|
fileSearchResultsRef.current = fileSearchResults
|
||||||
|
}, [fileSearchResults])
|
||||||
|
useEffect(() => {
|
||||||
|
allSlashCommandsRef.current = allSlashCommands
|
||||||
|
}, [allSlashCommands])
|
||||||
|
useEffect(() => {
|
||||||
|
availableModesRef.current = availableModes
|
||||||
|
}, [availableModes])
|
||||||
|
useEffect(() => {
|
||||||
|
taskHistoryRef.current = taskHistory
|
||||||
|
}, [taskHistory])
|
||||||
|
|
||||||
|
// Scroll area state
|
||||||
|
const { rows } = useTerminalSize()
|
||||||
|
const [scrollState, setScrollState] = useState({ scrollTop: 0, maxScroll: 0, isAtBottom: true })
|
||||||
|
const { scrollToBottomTrigger, scrollToBottom } = useScrollToBottom()
|
||||||
|
|
||||||
|
// RAF-style throttle refs for scroll updates (prevents multiple state updates per event loop tick)
|
||||||
|
const rafIdRef = useRef<NodeJS.Immediate | null>(null)
|
||||||
|
const pendingScrollRef = useRef<{ scrollTop: number; maxScroll: number; isAtBottom: boolean } | null>(null)
|
||||||
|
|
||||||
|
// Toast notifications for ephemeral messages (e.g., mode changes)
|
||||||
|
const { currentToast, showInfo } = useToast()
|
||||||
|
|
||||||
|
// Initialize message handlers hook - provides refs and handler
|
||||||
|
const {
|
||||||
|
handleExtensionMessage,
|
||||||
|
seenMessageIds,
|
||||||
|
pendingCommandRef: _pendingCommandRef,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
} = useMessageHandlers({
|
||||||
|
verbose,
|
||||||
|
nonInteractive,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize extension host hook
|
||||||
|
const { sendToExtension, runTask, cleanup } = useExtensionHost({
|
||||||
|
initialPrompt,
|
||||||
|
mode,
|
||||||
|
reasoningEffort,
|
||||||
|
apiProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
workspacePath,
|
||||||
|
extensionPath,
|
||||||
|
verbose,
|
||||||
|
debug,
|
||||||
|
nonInteractive,
|
||||||
|
ephemeral,
|
||||||
|
exitOnComplete,
|
||||||
|
onExtensionMessage: handleExtensionMessage,
|
||||||
|
createExtensionHost,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize task submit hook
|
||||||
|
const { handleSubmit, handleApprove, handleReject } = useTaskSubmit({
|
||||||
|
sendToExtension,
|
||||||
|
runTask,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize focus management hook
|
||||||
|
const { canToggleFocus, isScrollAreaActive, isInputAreaActive, toggleFocus } = useFocusManagement({
|
||||||
|
showApprovalPrompt: Boolean(pendingAsk && pendingAsk.type !== "followup"),
|
||||||
|
pendingAsk,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize countdown hook for followup auto-accept
|
||||||
|
const { cancelCountdown } = useFollowupCountdown({
|
||||||
|
pendingAsk,
|
||||||
|
onAutoSubmit: handleSubmit,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize picker handlers hook
|
||||||
|
const { handlePickerStateChange, handlePickerSelect, handlePickerClose, handlePickerIndexChange } =
|
||||||
|
usePickerHandlers({
|
||||||
|
autocompleteRef,
|
||||||
|
followupAutocompleteRef,
|
||||||
|
sendToExtension,
|
||||||
|
showInfo,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize global input hook
|
||||||
|
useGlobalInput({
|
||||||
|
canToggleFocus,
|
||||||
|
isScrollAreaActive,
|
||||||
|
pickerIsOpen: pickerState.isOpen,
|
||||||
|
availableModes,
|
||||||
|
currentMode,
|
||||||
|
mode,
|
||||||
|
sendToExtension,
|
||||||
|
showInfo,
|
||||||
|
exit,
|
||||||
|
cleanup,
|
||||||
|
toggleFocus,
|
||||||
|
closePicker: handlePickerClose,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine current view
|
||||||
|
const view = getView(messages, pendingAsk, isLoading)
|
||||||
|
|
||||||
|
// Determine if we should show the approval prompt (Y/N) instead of text input
|
||||||
|
const showApprovalPrompt = pendingAsk && pendingAsk.type !== "followup"
|
||||||
|
|
||||||
|
// Display all messages including partial (streaming) ones
|
||||||
|
const displayMessages = useMemo(() => {
|
||||||
|
return messages
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Scroll to bottom when new messages arrive (if auto-scroll is enabled)
|
||||||
|
const prevMessageCount = useRef(messages.length)
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > prevMessageCount.current && scrollState.isAtBottom) {
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
prevMessageCount.current = messages.length
|
||||||
|
}, [messages.length, scrollState.isAtBottom, scrollToBottom])
|
||||||
|
|
||||||
|
// Handle scroll state changes from ScrollArea (RAF-throttled to coalesce rapid updates)
|
||||||
|
const handleScroll = useCallback((scrollTop: number, maxScroll: number, isAtBottom: boolean) => {
|
||||||
|
// Store the latest scroll values in ref
|
||||||
|
pendingScrollRef.current = { scrollTop, maxScroll, isAtBottom }
|
||||||
|
|
||||||
|
// Only schedule one update per event loop tick
|
||||||
|
if (rafIdRef.current === null) {
|
||||||
|
rafIdRef.current = setImmediate(() => {
|
||||||
|
rafIdRef.current = null
|
||||||
|
const pending = pendingScrollRef.current
|
||||||
|
if (pending) {
|
||||||
|
setScrollState(pending)
|
||||||
|
pendingScrollRef.current = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Cleanup RAF-style timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
clearImmediate(rafIdRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// File search handler for the file trigger
|
||||||
|
const handleFileSearch = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
if (!sendToExtension) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendToExtension({ type: "searchFiles", query })
|
||||||
|
},
|
||||||
|
[sendToExtension],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create autocomplete triggers
|
||||||
|
// Using 'any' to allow mixing different trigger types (FileResult, SlashCommandResult, ModeResult, HelpShortcutResult, HistoryResult)
|
||||||
|
// IMPORTANT: We use refs here to avoid recreating triggers every time data changes.
|
||||||
|
// This prevents the UI flash caused by: data change -> memo recreation -> re-render with stale state
|
||||||
|
// The getResults/getCommands/getModes/getHistory callbacks always read from refs to get fresh data.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const autocompleteTriggers = useMemo((): AutocompleteTrigger<any>[] => {
|
||||||
|
const fileTrigger = createFileTrigger({
|
||||||
|
onSearch: handleFileSearch,
|
||||||
|
getResults: () => {
|
||||||
|
const results = fileSearchResultsRef.current
|
||||||
|
return results.map(toFileResult)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const slashCommandTrigger = createSlashCommandTrigger({
|
||||||
|
getCommands: () => {
|
||||||
|
// Merge CLI global commands with extension commands
|
||||||
|
const extensionCommands = allSlashCommandsRef.current.map(toSlashCommandResult)
|
||||||
|
const globalCommands = getGlobalCommandsForAutocomplete().map(toSlashCommandResult)
|
||||||
|
// Global commands appear first, then extension commands
|
||||||
|
return [...globalCommands, ...extensionCommands]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const modeTrigger = createModeTrigger({
|
||||||
|
getModes: () => availableModesRef.current.map(toModeResult),
|
||||||
|
})
|
||||||
|
|
||||||
|
const helpTrigger = createHelpTrigger()
|
||||||
|
|
||||||
|
// History trigger - type # to search and resume previous tasks
|
||||||
|
const historyTrigger = createHistoryTrigger({
|
||||||
|
getHistory: () => {
|
||||||
|
// Filter to only show tasks for the current workspace
|
||||||
|
// Use arePathsEqual for proper cross-platform path comparison
|
||||||
|
// (handles trailing slashes, separators, and case sensitivity)
|
||||||
|
const history = taskHistoryRef.current
|
||||||
|
const filtered = history.filter((item) => arePathsEqual(item.workspace, workspacePath))
|
||||||
|
return filtered.map(toHistoryResult)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return [fileTrigger, slashCommandTrigger, modeTrigger, helpTrigger, historyTrigger]
|
||||||
|
}, [handleFileSearch, workspacePath]) // Only depend on handleFileSearch and workspacePath - data accessed via refs
|
||||||
|
|
||||||
|
// Refresh search results when fileSearchResults changes while file picker is open
|
||||||
|
// This handles the async timing where API results arrive after initial search
|
||||||
|
// IMPORTANT: Only run when fileSearchResults array identity changes (new API response)
|
||||||
|
// We use a ref to track this and avoid depending on pickerState in the effect
|
||||||
|
const prevFileSearchResultsRef = useRef(fileSearchResults)
|
||||||
|
const pickerStateRef = useRef(pickerState)
|
||||||
|
pickerStateRef.current = pickerState
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run if fileSearchResults actually changed (different array reference)
|
||||||
|
if (fileSearchResults === prevFileSearchResultsRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPickerState = pickerStateRef.current
|
||||||
|
const willRefresh =
|
||||||
|
currentPickerState.isOpen && currentPickerState.activeTrigger?.id === "file" && fileSearchResults.length > 0
|
||||||
|
|
||||||
|
prevFileSearchResultsRef.current = fileSearchResults
|
||||||
|
|
||||||
|
// Only refresh when file picker is open and we have new results
|
||||||
|
if (willRefresh) {
|
||||||
|
autocompleteRef.current?.refreshSearch()
|
||||||
|
followupAutocompleteRef.current?.refreshSearch()
|
||||||
|
}
|
||||||
|
}, [fileSearchResults]) // Only depend on fileSearchResults - read pickerState from ref
|
||||||
|
|
||||||
|
// Handle Y/N input for approval prompts
|
||||||
|
useInput((input) => {
|
||||||
|
if (pendingAsk && pendingAsk.type !== "followup") {
|
||||||
|
const lower = input.toLowerCase()
|
||||||
|
|
||||||
|
if (lower === "y") {
|
||||||
|
handleApprove()
|
||||||
|
} else if (lower === "n") {
|
||||||
|
handleReject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cancel countdown timer when user navigates in the followup suggestion menu
|
||||||
|
// This provides better UX - any user interaction cancels the auto-accept timer
|
||||||
|
const showFollowupSuggestions =
|
||||||
|
pendingAsk?.type === "followup" &&
|
||||||
|
pendingAsk.suggestions &&
|
||||||
|
pendingAsk.suggestions.length > 0 &&
|
||||||
|
!showCustomInput
|
||||||
|
|
||||||
|
useInput((_input, key) => {
|
||||||
|
// Only handle when followup suggestions are shown and countdown is active
|
||||||
|
if (showFollowupSuggestions && countdownSeconds !== null) {
|
||||||
|
// Cancel countdown on any arrow key navigation
|
||||||
|
if (key.upArrow || key.downArrow) {
|
||||||
|
cancelCountdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error display
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Text color="red" bold>
|
||||||
|
Error: {error}
|
||||||
|
</Text>
|
||||||
|
<Text color="gray" dimColor>
|
||||||
|
Press Ctrl+C to exit
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status bar content
|
||||||
|
// Priority: Toast > Exit hint > Loading > Scroll indicator > Input hint
|
||||||
|
// Don't show spinner when waiting for user input (pendingAsk is set)
|
||||||
|
const statusBarMessage = currentToast ? (
|
||||||
|
<ToastDisplay toast={currentToast} />
|
||||||
|
) : showExitHint ? (
|
||||||
|
<Text color="yellow">Press Ctrl+C again to exit</Text>
|
||||||
|
) : isLoading && !pendingAsk ? (
|
||||||
|
<Box>
|
||||||
|
<LoadingText>{view === "ToolUse" ? "Using tool" : "Thinking"}</LoadingText>
|
||||||
|
<Text color={theme.dimText}> • </Text>
|
||||||
|
<Text color={theme.dimText}>Esc to cancel</Text>
|
||||||
|
{isScrollAreaActive && (
|
||||||
|
<>
|
||||||
|
<Text color={theme.dimText}> • </Text>
|
||||||
|
<ScrollIndicator
|
||||||
|
scrollTop={scrollState.scrollTop}
|
||||||
|
maxScroll={scrollState.maxScroll}
|
||||||
|
isScrollFocused={true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : isScrollAreaActive ? (
|
||||||
|
<ScrollIndicator scrollTop={scrollState.scrollTop} maxScroll={scrollState.maxScroll} isScrollFocused={true} />
|
||||||
|
) : isInputAreaActive ? (
|
||||||
|
<Text color={theme.dimText}>? for shortcuts</Text>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
// Get render function for picker items based on active trigger
|
||||||
|
const getPickerRenderItem = () => {
|
||||||
|
if (pickerState.activeTrigger) {
|
||||||
|
return pickerState.activeTrigger.renderItem
|
||||||
|
}
|
||||||
|
// Default render
|
||||||
|
return (item: FileResult | SlashCommandResult, isSelected: boolean) => (
|
||||||
|
<Box paddingLeft={2}>
|
||||||
|
<Text color={isSelected ? "cyan" : undefined}>{item.key}</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height={rows - 1}>
|
||||||
|
{/* Header - fixed size */}
|
||||||
|
<Box flexShrink={0}>
|
||||||
|
<Header
|
||||||
|
model={model}
|
||||||
|
mode={currentMode || mode}
|
||||||
|
cwd={workspacePath}
|
||||||
|
reasoningEffort={reasoningEffort}
|
||||||
|
version={version}
|
||||||
|
tokenUsage={tokenUsage}
|
||||||
|
contextWindow={contextWindow}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Scrollable message history area - fills remaining space via flexGrow */}
|
||||||
|
<ScrollArea
|
||||||
|
isActive={isScrollAreaActive}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
scrollToBottomTrigger={scrollToBottomTrigger}>
|
||||||
|
{displayMessages.map((message) => (
|
||||||
|
<ChatHistoryItem key={message.id} message={message} />
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Input area - with borders like Claude Code - fixed size */}
|
||||||
|
<Box flexDirection="column" flexShrink={0}>
|
||||||
|
{pendingAsk?.type === "followup" ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={theme.rooHeader}>{pendingAsk.content}</Text>
|
||||||
|
{pendingAsk.suggestions && pendingAsk.suggestions.length > 0 && !showCustomInput ? (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<HorizontalLine active={true} />
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
...pendingAsk.suggestions.map((s) => ({
|
||||||
|
label: s.answer,
|
||||||
|
value: s.answer,
|
||||||
|
})),
|
||||||
|
{ label: "Type something...", value: "__CUSTOM__" },
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!value || typeof value !== "string") return
|
||||||
|
if (showCustomInput || isTransitioningToCustomInput) return
|
||||||
|
|
||||||
|
if (value === "__CUSTOM__") {
|
||||||
|
// Clear countdown timer and switch to custom input
|
||||||
|
cancelCountdown()
|
||||||
|
setIsTransitioningToCustomInput(true)
|
||||||
|
useUIStateStore.getState().setShowCustomInput(true)
|
||||||
|
} else if (value.trim()) {
|
||||||
|
handleSubmit(value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HorizontalLine active={true} />
|
||||||
|
<Text color={theme.dimText}>
|
||||||
|
↑↓ navigate • Enter select
|
||||||
|
{countdownSeconds !== null && (
|
||||||
|
<Text color="yellow"> • Auto-select in {countdownSeconds}s</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
<HorizontalLine active={isInputAreaActive} />
|
||||||
|
<AutocompleteInput
|
||||||
|
ref={followupAutocompleteRef}
|
||||||
|
placeholder="Type your response..."
|
||||||
|
onSubmit={(text: string) => {
|
||||||
|
if (text && text.trim()) {
|
||||||
|
handleSubmit(text)
|
||||||
|
useUIStateStore.getState().setShowCustomInput(false)
|
||||||
|
setIsTransitioningToCustomInput(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isActive={true}
|
||||||
|
triggers={autocompleteTriggers}
|
||||||
|
onPickerStateChange={handlePickerStateChange}
|
||||||
|
prompt="> "
|
||||||
|
/>
|
||||||
|
<HorizontalLine active={isInputAreaActive} />
|
||||||
|
{pickerState.isOpen ? (
|
||||||
|
<Box flexDirection="column" height={PICKER_HEIGHT}>
|
||||||
|
<PickerSelect
|
||||||
|
results={pickerState.results}
|
||||||
|
selectedIndex={pickerState.selectedIndex}
|
||||||
|
maxVisible={PICKER_HEIGHT - 1}
|
||||||
|
onSelect={handlePickerSelect}
|
||||||
|
onEscape={handlePickerClose}
|
||||||
|
onIndexChange={handlePickerIndexChange}
|
||||||
|
renderItem={getPickerRenderItem()}
|
||||||
|
emptyMessage={pickerState.activeTrigger?.emptyMessage}
|
||||||
|
isActive={isInputAreaActive && pickerState.isOpen}
|
||||||
|
isLoading={pickerState.isLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box height={1}>{statusBarMessage}</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : showApprovalPrompt ? (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={theme.rooHeader}>{pendingAsk?.content}</Text>
|
||||||
|
<Text color={theme.dimText}>
|
||||||
|
Press <Text color={theme.successColor}>Y</Text> to approve,{" "}
|
||||||
|
<Text color={theme.errorColor}>N</Text> to reject
|
||||||
|
</Text>
|
||||||
|
<Box height={1}>{statusBarMessage}</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<HorizontalLine active={isInputAreaActive} />
|
||||||
|
<AutocompleteInput
|
||||||
|
ref={autocompleteRef}
|
||||||
|
placeholder={isComplete ? "Type to continue..." : ""}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isActive={isInputAreaActive}
|
||||||
|
triggers={autocompleteTriggers}
|
||||||
|
onPickerStateChange={handlePickerStateChange}
|
||||||
|
prompt="› "
|
||||||
|
/>
|
||||||
|
<HorizontalLine active={isInputAreaActive} />
|
||||||
|
{showTodoViewer ? (
|
||||||
|
<Box flexDirection="column" height={PICKER_HEIGHT}>
|
||||||
|
<TodoDisplay todos={currentTodos} showProgress={true} title="TODO List" />
|
||||||
|
<Box height={1}>
|
||||||
|
<Text color={theme.dimText}>Ctrl+T to close</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : pickerState.isOpen ? (
|
||||||
|
<Box flexDirection="column" height={PICKER_HEIGHT}>
|
||||||
|
<PickerSelect
|
||||||
|
results={pickerState.results}
|
||||||
|
selectedIndex={pickerState.selectedIndex}
|
||||||
|
maxVisible={PICKER_HEIGHT - 1}
|
||||||
|
onSelect={handlePickerSelect}
|
||||||
|
onEscape={handlePickerClose}
|
||||||
|
onIndexChange={handlePickerIndexChange}
|
||||||
|
renderItem={getPickerRenderItem()}
|
||||||
|
emptyMessage={pickerState.activeTrigger?.emptyMessage}
|
||||||
|
isActive={isInputAreaActive && pickerState.isOpen}
|
||||||
|
isLoading={pickerState.isLoading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box height={1}>{statusBarMessage}</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main TUI Application Component - wraps with TerminalSizeProvider
|
||||||
|
*/
|
||||||
|
export function App(props: TUIAppProps) {
|
||||||
|
return (
|
||||||
|
<TerminalSizeProvider>
|
||||||
|
<AppInner {...props} />
|
||||||
|
</TerminalSizeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
276
apps/cli/src/ui/__tests__/store.test.ts
Normal file
276
apps/cli/src/ui/__tests__/store.test.ts
Normal 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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
251
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal file
251
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal 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)
|
||||||
67
apps/cli/src/ui/components/Header.tsx
Normal file
67
apps/cli/src/ui/components/Header.tsx
Normal 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)
|
||||||
16
apps/cli/src/ui/components/HorizontalLine.tsx
Normal file
16
apps/cli/src/ui/components/HorizontalLine.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Text } from "ink"
|
||||||
|
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
|
||||||
|
import * as theme from "../theme.js"
|
||||||
|
|
||||||
|
interface HorizontalLineProps {
|
||||||
|
active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-width horizontal line component - uses terminal size from context
|
||||||
|
*/
|
||||||
|
export function HorizontalLine({ active = false }: HorizontalLineProps) {
|
||||||
|
const { columns } = useTerminalSize()
|
||||||
|
const color = active ? theme.borderColorActive : theme.borderColor
|
||||||
|
return <Text color={color}>{"─".repeat(columns)}</Text>
|
||||||
|
}
|
||||||
174
apps/cli/src/ui/components/Icon.tsx
Normal file
174
apps/cli/src/ui/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { Box, Text } from "ink"
|
||||||
|
import type { TextProps } from "ink"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon names supported by the Icon component.
|
||||||
|
* Each icon has a Nerd Font glyph and an ASCII fallback.
|
||||||
|
*/
|
||||||
|
export type IconName =
|
||||||
|
| "folder"
|
||||||
|
| "file"
|
||||||
|
| "file-edit"
|
||||||
|
| "check"
|
||||||
|
| "cross"
|
||||||
|
| "arrow-right"
|
||||||
|
| "bullet"
|
||||||
|
| "spinner"
|
||||||
|
// Tool-related icons
|
||||||
|
| "search"
|
||||||
|
| "terminal"
|
||||||
|
| "browser"
|
||||||
|
| "switch"
|
||||||
|
| "question"
|
||||||
|
| "gear"
|
||||||
|
| "diff"
|
||||||
|
// TODO-related icons
|
||||||
|
| "checkbox"
|
||||||
|
| "checkbox-checked"
|
||||||
|
| "checkbox-progress"
|
||||||
|
| "todo-list"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon definitions with Nerd Font glyph and ASCII fallback.
|
||||||
|
* Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char).
|
||||||
|
*/
|
||||||
|
const ICONS: Record<IconName, { nerd: string; fallback: string }> = {
|
||||||
|
folder: { nerd: "\uf413", fallback: "▼" },
|
||||||
|
file: { nerd: "\uf4a5", fallback: "●" },
|
||||||
|
"file-edit": { nerd: "\uf4d2", fallback: "✎" },
|
||||||
|
check: { nerd: "\uf42e", fallback: "✓" },
|
||||||
|
cross: { nerd: "\uf517", fallback: "✗" },
|
||||||
|
"arrow-right": { nerd: "\uf432", fallback: "→" },
|
||||||
|
bullet: { nerd: "\uf444", fallback: "•" },
|
||||||
|
spinner: { nerd: "\uf4e3", fallback: "*" },
|
||||||
|
// Tool-related icons
|
||||||
|
search: { nerd: "\uf422", fallback: "🔍" },
|
||||||
|
terminal: { nerd: "\uf489", fallback: "$" },
|
||||||
|
browser: { nerd: "\uf488", fallback: "🌐" },
|
||||||
|
switch: { nerd: "\uf443", fallback: "⇄" },
|
||||||
|
question: { nerd: "\uf420", fallback: "?" },
|
||||||
|
gear: { nerd: "\uf423", fallback: "⚙" },
|
||||||
|
diff: { nerd: "\uf4d2", fallback: "±" },
|
||||||
|
// TODO-related icons
|
||||||
|
checkbox: { nerd: "\uf4aa", fallback: "○" }, // Empty checkbox
|
||||||
|
"checkbox-checked": { nerd: "\uf4a4", fallback: "✓" }, // Checked checkbox
|
||||||
|
"checkbox-progress": { nerd: "\uf4aa", fallback: "→" }, // In progress (dot circle)
|
||||||
|
"todo-list": { nerd: "\uf45e", fallback: "☑" }, // List icon for TODO header
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string contains surrogate pairs (characters outside BMP).
|
||||||
|
* Surrogate pairs have .length of 2 but render as 1 visual character.
|
||||||
|
*/
|
||||||
|
function containsSurrogatePair(str: string): boolean {
|
||||||
|
// Surrogate pairs are in the range U+D800 to U+DFFF
|
||||||
|
return /[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if Nerd Font icons are likely supported.
|
||||||
|
*
|
||||||
|
* Users can override this with the ROOCODE_NERD_FONT environment variable:
|
||||||
|
* - ROOCODE_NERD_FONT=0 to force ASCII fallbacks (if icons don't render correctly)
|
||||||
|
* - ROOCODE_NERD_FONT=1 to force Nerd Font icons
|
||||||
|
*
|
||||||
|
* Defaults to true because:
|
||||||
|
* 1. Nerd Fonts are common in developer terminal setups
|
||||||
|
* 2. Modern terminals handle missing glyphs gracefully
|
||||||
|
* 3. Users can easily disable if icons don't render correctly
|
||||||
|
*/
|
||||||
|
function detectNerdFontSupport(): boolean {
|
||||||
|
// Allow explicit override via environment variable
|
||||||
|
const envOverride = process.env.ROOCODE_NERD_FONT
|
||||||
|
if (envOverride === "0" || envOverride === "false") return false
|
||||||
|
if (envOverride === "1" || envOverride === "true") return true
|
||||||
|
|
||||||
|
// Default to Nerd Font icons - they're common in developer setups
|
||||||
|
// and users can set ROOCODE_NERD_FONT=0 if needed
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the detection result
|
||||||
|
let nerdFontSupported: boolean | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get whether Nerd Font icons are supported (cached).
|
||||||
|
*/
|
||||||
|
export function isNerdFontSupported(): boolean {
|
||||||
|
if (nerdFontSupported === null) {
|
||||||
|
nerdFontSupported = detectNerdFontSupport()
|
||||||
|
}
|
||||||
|
return nerdFontSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the Nerd Font detection cache (useful for testing).
|
||||||
|
*/
|
||||||
|
export function resetNerdFontCache(): void {
|
||||||
|
nerdFontSupported = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconProps extends Omit<TextProps, "children"> {
|
||||||
|
/** The icon to display */
|
||||||
|
name: IconName
|
||||||
|
/** Override the automatic Nerd Font detection */
|
||||||
|
useNerdFont?: boolean
|
||||||
|
/** Custom width for the icon container (default: 2) */
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icon component that renders Nerd Font icons with ASCII fallbacks.
|
||||||
|
*
|
||||||
|
* Renders icons in a fixed-width Box to handle surrogate pair width
|
||||||
|
* calculation issues in Ink. Surrogate pairs (like Nerd Font glyphs)
|
||||||
|
* have .length of 2 in JavaScript but render as 1 visual character.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Icon name="folder" color="blue" />
|
||||||
|
* <Icon name="file" />
|
||||||
|
* <Icon name="check" color="green" useNerdFont={false} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Icon({ name, useNerdFont, width = 2, color, ...textProps }: IconProps) {
|
||||||
|
const iconDef = ICONS[name]
|
||||||
|
if (!iconDef) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
|
||||||
|
const icon = shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
|
||||||
|
|
||||||
|
// Use fixed-width Box to isolate surrogate pair width calculation
|
||||||
|
// from surrounding text. This prevents the off-by-one truncation bug.
|
||||||
|
const needsWidthFix = containsSurrogatePair(icon)
|
||||||
|
|
||||||
|
if (needsWidthFix) {
|
||||||
|
return (
|
||||||
|
<Box width={width}>
|
||||||
|
<Text color={color} {...textProps}>
|
||||||
|
{icon}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For BMP characters (no surrogate pairs), render directly
|
||||||
|
return (
|
||||||
|
<Text color={color} {...textProps}>
|
||||||
|
{icon}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the raw icon character (useful for string concatenation).
|
||||||
|
*/
|
||||||
|
export function getIconChar(name: IconName, useNerdFont?: boolean): string {
|
||||||
|
const iconDef = ICONS[name]
|
||||||
|
if (!iconDef) return ""
|
||||||
|
|
||||||
|
const shouldUseNerdFont = useNerdFont ?? isNerdFontSupported()
|
||||||
|
return shouldUseNerdFont ? iconDef.nerd : iconDef.fallback
|
||||||
|
}
|
||||||
41
apps/cli/src/ui/components/LoadingText.tsx
Normal file
41
apps/cli/src/ui/components/LoadingText.tsx
Normal 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)
|
||||||
68
apps/cli/src/ui/components/MetricsDisplay.tsx
Normal file
68
apps/cli/src/ui/components/MetricsDisplay.tsx
Normal 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 }
|
||||||
493
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal file
493
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal 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>
|
||||||
|
}
|
||||||
61
apps/cli/src/ui/components/ProgressBar.tsx
Normal file
61
apps/cli/src/ui/components/ProgressBar.tsx
Normal 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)
|
||||||
398
apps/cli/src/ui/components/ScrollArea.tsx
Normal file
398
apps/cli/src/ui/components/ScrollArea.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/cli/src/ui/components/ScrollIndicator.tsx
Normal file
26
apps/cli/src/ui/components/ScrollIndicator.tsx
Normal 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)
|
||||||
69
apps/cli/src/ui/components/ToastDisplay.tsx
Normal file
69
apps/cli/src/ui/components/ToastDisplay.tsx
Normal 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)
|
||||||
142
apps/cli/src/ui/components/TodoChangeDisplay.tsx
Normal file
142
apps/cli/src/ui/components/TodoChangeDisplay.tsx
Normal 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)
|
||||||
163
apps/cli/src/ui/components/TodoDisplay.tsx
Normal file
163
apps/cli/src/ui/components/TodoDisplay.tsx
Normal 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)
|
||||||
385
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal file
385
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal 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?")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
162
apps/cli/src/ui/components/__tests__/Icon.test.tsx
Normal file
162
apps/cli/src/ui/components/__tests__/Icon.test.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { render } from "ink-testing-library"
|
||||||
|
|
||||||
|
import { Icon, isNerdFontSupported, resetNerdFontCache, getIconChar } from "../Icon.js"
|
||||||
|
|
||||||
|
describe("Icon", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset cache before each test
|
||||||
|
resetNerdFontCache()
|
||||||
|
// Clear environment variables
|
||||||
|
delete process.env.ROOCODE_NERD_FONT
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetNerdFontCache()
|
||||||
|
delete process.env.ROOCODE_NERD_FONT
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("should render folder icon", () => {
|
||||||
|
const { lastFrame } = render(<Icon name="folder" />)
|
||||||
|
// Should render something (either nerd font or fallback)
|
||||||
|
expect(lastFrame()).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render file icon", () => {
|
||||||
|
const { lastFrame } = render(<Icon name="file" />)
|
||||||
|
expect(lastFrame()).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render check icon", () => {
|
||||||
|
const { lastFrame } = render(<Icon name="check" />)
|
||||||
|
expect(lastFrame()).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render cross icon", () => {
|
||||||
|
const { lastFrame } = render(<Icon name="cross" />)
|
||||||
|
expect(lastFrame()).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should apply color prop", () => {
|
||||||
|
const { lastFrame } = render(<Icon name="file" color="blue" />)
|
||||||
|
expect(lastFrame()).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return null for unknown icon name", () => {
|
||||||
|
// @ts-expect-error - testing invalid icon name
|
||||||
|
const { lastFrame } = render(<Icon name="unknown-icon" />)
|
||||||
|
expect(lastFrame()).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Nerd Font detection", () => {
|
||||||
|
it("should respect ROOCODE_NERD_FONT=1 environment variable", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect ROOCODE_NERD_FONT=true environment variable", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "true"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect ROOCODE_NERD_FONT=0 environment variable", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect ROOCODE_NERD_FONT=false environment variable", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "false"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should cache detection result", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
const first = isNerdFontSupported()
|
||||||
|
// Change env var - should still use cached value
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
const second = isNerdFontSupported()
|
||||||
|
expect(first).toBe(true)
|
||||||
|
expect(second).toBe(true) // Still true because cached
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reset cache when resetNerdFontCache is called", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(true)
|
||||||
|
|
||||||
|
// Reset and change
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
resetNerdFontCache()
|
||||||
|
expect(isNerdFontSupported()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useNerdFont prop override", () => {
|
||||||
|
it("should force Nerd Font when useNerdFont=true", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
const { lastFrame } = render(<Icon name="folder" useNerdFont={true} />)
|
||||||
|
// The nerd font icon is a surrogate pair
|
||||||
|
const frame = lastFrame() || ""
|
||||||
|
// Surrogate pair should be present (even if it renders oddly in tests)
|
||||||
|
expect(frame.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should force fallback when useNerdFont=false", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
const { lastFrame } = render(<Icon name="folder" useNerdFont={false} />)
|
||||||
|
const frame = lastFrame() || ""
|
||||||
|
// Fallback for folder is "▼" (single char)
|
||||||
|
expect(frame).toContain("▼")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getIconChar", () => {
|
||||||
|
it("should return fallback character when Nerd Font disabled", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
expect(getIconChar("folder")).toBe("▼")
|
||||||
|
expect(getIconChar("file")).toBe("●")
|
||||||
|
expect(getIconChar("check")).toBe("✓")
|
||||||
|
expect(getIconChar("cross")).toBe("✗")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return Nerd Font character when enabled", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
// Nerd Font icons are single characters (length 1)
|
||||||
|
expect(getIconChar("folder").length).toBe(1)
|
||||||
|
expect(getIconChar("file").length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should respect useNerdFont override", () => {
|
||||||
|
process.env.ROOCODE_NERD_FONT = "1"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
// Force fallback
|
||||||
|
expect(getIconChar("folder", false)).toBe("▼")
|
||||||
|
|
||||||
|
process.env.ROOCODE_NERD_FONT = "0"
|
||||||
|
resetNerdFontCache()
|
||||||
|
|
||||||
|
// Force Nerd Font
|
||||||
|
expect(getIconChar("folder", true).length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty string for unknown icon", () => {
|
||||||
|
// @ts-expect-error - testing invalid icon name
|
||||||
|
expect(getIconChar("unknown")).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
86
apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx
Normal file
86
apps/cli/src/ui/components/__tests__/ToastDisplay.test.tsx
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
149
apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx
Normal file
149
apps/cli/src/ui/components/__tests__/TodoChangeDisplay.test.tsx
Normal 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]")
|
||||||
|
})
|
||||||
|
})
|
||||||
152
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal file
152
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
320
apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx
Normal file
320
apps/cli/src/ui/components/autocomplete/AutocompleteInput.tsx
Normal 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"
|
||||||
189
apps/cli/src/ui/components/autocomplete/PickerSelect.tsx
Normal file
189
apps/cli/src/ui/components/autocomplete/PickerSelect.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
apps/cli/src/ui/components/autocomplete/index.ts
Normal file
41
apps/cli/src/ui/components/autocomplete/index.ts
Normal 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"
|
||||||
140
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal file
140
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal file
109
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { Box, Text } from "ink"
|
||||||
|
|
||||||
|
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help shortcut result type.
|
||||||
|
* Represents a keyboard shortcut or trigger hint.
|
||||||
|
*/
|
||||||
|
export interface HelpShortcutResult extends AutocompleteItem {
|
||||||
|
/** The shortcut key or trigger character */
|
||||||
|
shortcut: string
|
||||||
|
/** Description of what the shortcut does */
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in shortcuts to display in the help menu.
|
||||||
|
*/
|
||||||
|
const HELP_SHORTCUTS: HelpShortcutResult[] = [
|
||||||
|
{ key: "slash", shortcut: "/", description: "for commands" },
|
||||||
|
{ key: "at", shortcut: "@", description: "for file paths" },
|
||||||
|
{ key: "bang", shortcut: "!", description: "for modes" },
|
||||||
|
{ key: "hash", shortcut: "#", description: "for task history" },
|
||||||
|
{ key: "newline", shortcut: "shift + ⏎", description: "for newline" },
|
||||||
|
{ key: "focus", shortcut: "tab", description: "to toggle focus" },
|
||||||
|
{ key: "mode", shortcut: "ctrl + m", description: "to cycle modes" },
|
||||||
|
{ key: "todos", shortcut: "ctrl + t", description: "to view TODO list" },
|
||||||
|
{ key: "quit", shortcut: "ctrl + c", description: "to quit" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a help trigger for ? shortcuts menu.
|
||||||
|
*
|
||||||
|
* This trigger activates when the user types ? at the start of a line,
|
||||||
|
* and displays a menu of available keyboard shortcuts.
|
||||||
|
*
|
||||||
|
* @returns AutocompleteTrigger for help shortcuts
|
||||||
|
*/
|
||||||
|
export function createHelpTrigger(): AutocompleteTrigger<HelpShortcutResult> {
|
||||||
|
return {
|
||||||
|
id: "help",
|
||||||
|
triggerChar: "?",
|
||||||
|
position: "line-start",
|
||||||
|
consumeTrigger: true,
|
||||||
|
|
||||||
|
detectTrigger: (lineText: string): TriggerDetectionResult | null => {
|
||||||
|
// Check if line starts with ? (after optional whitespace)
|
||||||
|
const trimmed = lineText.trimStart()
|
||||||
|
|
||||||
|
if (!trimmed.startsWith("?")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract query after ?
|
||||||
|
const query = trimmed.substring(1)
|
||||||
|
|
||||||
|
// Close picker if query contains space
|
||||||
|
if (query.includes(" ")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trigger index (position of ? in original line)
|
||||||
|
const triggerIndex = lineText.length - trimmed.length
|
||||||
|
|
||||||
|
return { query, triggerIndex }
|
||||||
|
},
|
||||||
|
|
||||||
|
search: (query: string): HelpShortcutResult[] => {
|
||||||
|
if (query.length === 0) {
|
||||||
|
// Show all shortcuts when just "?" is typed
|
||||||
|
return HELP_SHORTCUTS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter shortcuts based on query
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
return HELP_SHORTCUTS.filter(
|
||||||
|
(item) =>
|
||||||
|
item.shortcut.toLowerCase().includes(lowerQuery) ||
|
||||||
|
item.description.toLowerCase().includes(lowerQuery),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
renderItem: (item: HelpShortcutResult, isSelected: boolean) => {
|
||||||
|
return (
|
||||||
|
<Box paddingLeft={2}>
|
||||||
|
<Text color={isSelected ? "cyan" : undefined}>
|
||||||
|
<Text bold color={isSelected ? "cyan" : "yellow"}>
|
||||||
|
{item.shortcut}
|
||||||
|
</Text>
|
||||||
|
<Text> {item.description}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
getReplacementText: (item: HelpShortcutResult, _lineText: string, _triggerIndex: number): string => {
|
||||||
|
// When a shortcut is selected, replace with the trigger character
|
||||||
|
// For action shortcuts (tab, ctrl+c, shift+enter, ctrl+t), just clear the input
|
||||||
|
if (["newline", "focus", "quit", "todos"].includes(item.key)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// For trigger shortcuts (/, @, !), insert the trigger character
|
||||||
|
return item.shortcut
|
||||||
|
},
|
||||||
|
|
||||||
|
emptyMessage: "No matching shortcuts",
|
||||||
|
debounceMs: 0, // No debounce needed for static list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal file
109
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { render } from "ink-testing-library"
|
||||||
|
|
||||||
|
import { createHelpTrigger, type HelpShortcutResult } from "../HelpTrigger.js"
|
||||||
|
|
||||||
|
describe("HelpTrigger", () => {
|
||||||
|
describe("createHelpTrigger", () => {
|
||||||
|
it("should detect ? trigger at line start", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const result = trigger.detectTrigger("?")
|
||||||
|
expect(result).toEqual({ query: "", triggerIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect ? trigger with query", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const result = trigger.detectTrigger("?slash")
|
||||||
|
expect(result).toEqual({ query: "slash", triggerIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect ? trigger after whitespace", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const result = trigger.detectTrigger(" ?")
|
||||||
|
expect(result).toEqual({ query: "", triggerIndex: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect ? in middle of text", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
// The trigger position is "line-start", so it should only match at start
|
||||||
|
const result = trigger.detectTrigger("some text ?")
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect ? followed by space", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const result = trigger.detectTrigger("? ")
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return all shortcuts when query is empty", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(9)
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("/")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("@")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("!")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("#")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("shift + ⏎")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("tab")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("ctrl + m")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("ctrl + c")
|
||||||
|
expect(results.map((r) => r.shortcut)).toContain("ctrl + t")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should include ctrl+t shortcut for TODO list", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("todo") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0]?.shortcut).toBe("ctrl + t")
|
||||||
|
expect(results[0]?.description).toContain("TODO")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should clear input for todos action shortcut", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const todosItem: HelpShortcutResult = {
|
||||||
|
key: "todos",
|
||||||
|
shortcut: "ctrl + t",
|
||||||
|
description: "to view TODO list",
|
||||||
|
}
|
||||||
|
const replacement = trigger.getReplacementText(todosItem, "?todo", 0)
|
||||||
|
expect(replacement).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter shortcuts by shortcut character", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("/") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0]?.shortcut).toBe("/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter shortcuts by description", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("file") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0]?.shortcut).toBe("@")
|
||||||
|
expect(results[0]?.description).toContain("file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should filter case-insensitively", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("QUIT") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(1)
|
||||||
|
expect(results[0]?.shortcut).toBe("ctrl + c")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for non-matching query", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const results = trigger.search("xyz") as HelpShortcutResult[]
|
||||||
|
expect(results.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should generate replacement text for trigger shortcuts", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const slashItem: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
|
||||||
|
const replacement = trigger.getReplacementText(slashItem, "?", 0)
|
||||||
|
expect(replacement).toBe("/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should clear input for action shortcuts", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const tabItem: HelpShortcutResult = { key: "focus", shortcut: "tab", description: "to toggle focus" }
|
||||||
|
const replacement = trigger.getReplacementText(tabItem, "?tab", 0)
|
||||||
|
expect(replacement).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render shortcut items correctly", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
|
||||||
|
const { lastFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
|
||||||
|
|
||||||
|
const output = lastFrame()
|
||||||
|
expect(output).toContain("/")
|
||||||
|
expect(output).toContain("for commands")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should render selected items with different styling", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
const item: HelpShortcutResult = { key: "slash", shortcut: "/", description: "for commands" }
|
||||||
|
const { lastFrame: unselectedFrame } = render(trigger.renderItem(item, false) as React.ReactElement)
|
||||||
|
const { lastFrame: selectedFrame } = render(trigger.renderItem(item, true) as React.ReactElement)
|
||||||
|
|
||||||
|
// Both should contain the content
|
||||||
|
expect(unselectedFrame()).toContain("/")
|
||||||
|
expect(selectedFrame()).toContain("/")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have correct trigger configuration", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
expect(trigger.id).toBe("help")
|
||||||
|
expect(trigger.triggerChar).toBe("?")
|
||||||
|
expect(trigger.position).toBe("line-start")
|
||||||
|
expect(trigger.emptyMessage).toBe("No matching shortcuts")
|
||||||
|
expect(trigger.debounceMs).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have consumeTrigger set to true", () => {
|
||||||
|
const trigger = createHelpTrigger()
|
||||||
|
|
||||||
|
// The ? character should be consumed (not inserted into input)
|
||||||
|
// when the help menu is triggered
|
||||||
|
expect(trigger.consumeTrigger).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
19
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal file
19
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal 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"
|
||||||
154
apps/cli/src/ui/components/autocomplete/types.ts
Normal file
154
apps/cli/src/ui/components/autocomplete/types.ts
Normal 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
|
||||||
|
}
|
||||||
411
apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts
Normal file
411
apps/cli/src/ui/components/autocomplete/useAutocompletePicker.ts
Normal 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]
|
||||||
|
}
|
||||||
91
apps/cli/src/ui/components/tools/BrowserTool.tsx
Normal file
91
apps/cli/src/ui/components/tools/BrowserTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
apps/cli/src/ui/components/tools/CommandTool.tsx
Normal file
49
apps/cli/src/ui/components/tools/CommandTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
apps/cli/src/ui/components/tools/CompletionTool.tsx
Normal file
39
apps/cli/src/ui/components/tools/CompletionTool.tsx
Normal 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
|
||||||
|
}
|
||||||
135
apps/cli/src/ui/components/tools/FileReadTool.tsx
Normal file
135
apps/cli/src/ui/components/tools/FileReadTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
apps/cli/src/ui/components/tools/FileWriteTool.tsx
Normal file
169
apps/cli/src/ui/components/tools/FileWriteTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
apps/cli/src/ui/components/tools/GenericTool.tsx
Normal file
97
apps/cli/src/ui/components/tools/GenericTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
apps/cli/src/ui/components/tools/ModeTool.tsx
Normal file
28
apps/cli/src/ui/components/tools/ModeTool.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Box, Text } from "ink"
|
||||||
|
|
||||||
|
import * as theme from "../../theme.js"
|
||||||
|
import { Icon } from "../Icon.js"
|
||||||
|
|
||||||
|
import type { ToolRendererProps } from "./types.js"
|
||||||
|
import { getToolIconName } from "./utils.js"
|
||||||
|
|
||||||
|
export function ModeTool({ toolData }: ToolRendererProps) {
|
||||||
|
const iconName = getToolIconName(toolData.tool)
|
||||||
|
const mode = toolData.mode || ""
|
||||||
|
const isSwitch = toolData.tool.includes("switch") || toolData.tool.includes("Switch")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" gap={1} paddingX={1} marginBottom={1}>
|
||||||
|
<Icon name={iconName} color={theme.toolHeader} />
|
||||||
|
{isSwitch && mode && (
|
||||||
|
<Box gap={1}>
|
||||||
|
<Text color={theme.dimText}>Switching to</Text>
|
||||||
|
<Text color={theme.userHeader} bold>
|
||||||
|
{mode}
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.dimText}>mode</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
apps/cli/src/ui/components/tools/SearchTool.tsx
Normal file
117
apps/cli/src/ui/components/tools/SearchTool.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx
Normal file
164
apps/cli/src/ui/components/tools/__tests__/CommandTool.test.tsx
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
63
apps/cli/src/ui/components/tools/index.ts
Normal file
63
apps/cli/src/ui/components/tools/index.ts
Normal 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
|
||||||
|
}
|
||||||
65
apps/cli/src/ui/components/tools/types.ts
Normal file
65
apps/cli/src/ui/components/tools/types.ts
Normal 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"
|
||||||
|
}
|
||||||
226
apps/cli/src/ui/components/tools/utils.ts
Normal file
226
apps/cli/src/ui/components/tools/utils.ts
Normal 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
|
||||||
|
}
|
||||||
38
apps/cli/src/ui/hooks/TerminalSizeContext.tsx
Normal file
38
apps/cli/src/ui/hooks/TerminalSizeContext.tsx
Normal 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
|
||||||
|
}
|
||||||
190
apps/cli/src/ui/hooks/__tests__/useToast.test.ts
Normal file
190
apps/cli/src/ui/hooks/__tests__/useToast.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
22
apps/cli/src/ui/hooks/index.ts
Normal file
22
apps/cli/src/ui/hooks/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Export existing hooks
|
||||||
|
export { TerminalSizeProvider, useTerminalSize } from "./TerminalSizeContext.js"
|
||||||
|
export { useToast, useToastStore } from "./useToast.js"
|
||||||
|
export { useInputHistory } from "./useInputHistory.js"
|
||||||
|
|
||||||
|
// Export new extracted hooks
|
||||||
|
export { useFollowupCountdown } from "./useFollowupCountdown.js"
|
||||||
|
export { useFocusManagement } from "./useFocusManagement.js"
|
||||||
|
export { useMessageHandlers } from "./useMessageHandlers.js"
|
||||||
|
export { useExtensionHost } from "./useExtensionHost.js"
|
||||||
|
export { useTaskSubmit } from "./useTaskSubmit.js"
|
||||||
|
export { useGlobalInput } from "./useGlobalInput.js"
|
||||||
|
export { usePickerHandlers } from "./usePickerHandlers.js"
|
||||||
|
|
||||||
|
// Export types
|
||||||
|
export type { UseFollowupCountdownOptions } from "./useFollowupCountdown.js"
|
||||||
|
export type { UseFocusManagementOptions, UseFocusManagementReturn } from "./useFocusManagement.js"
|
||||||
|
export type { UseMessageHandlersOptions, UseMessageHandlersReturn } from "./useMessageHandlers.js"
|
||||||
|
export type { UseExtensionHostOptions, UseExtensionHostReturn } from "./useExtensionHost.js"
|
||||||
|
export type { UseTaskSubmitOptions, UseTaskSubmitReturn } from "./useTaskSubmit.js"
|
||||||
|
export type { UseGlobalInputOptions } from "./useGlobalInput.js"
|
||||||
|
export type { UsePickerHandlersOptions, UsePickerHandlersReturn } from "./usePickerHandlers.js"
|
||||||
206
apps/cli/src/ui/hooks/useExtensionHost.ts
Normal file
206
apps/cli/src/ui/hooks/useExtensionHost.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { useEffect, useRef, useCallback, useMemo } from "react"
|
||||||
|
import { useApp } from "ink"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
import type { ExtensionMessage, WebviewMessage } from "@roo-code/types"
|
||||||
|
|
||||||
|
import { toolInspectorLog, clearToolInspectorLog } from "../../utils/toolInspectorLogger.js"
|
||||||
|
import { useCLIStore } from "../store.js"
|
||||||
|
|
||||||
|
interface ExtensionHostInterface {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
on(event: string, handler: (...args: any[]) => void): void
|
||||||
|
activate(): Promise<void>
|
||||||
|
runTask(prompt: string): Promise<void>
|
||||||
|
sendToExtension(message: WebviewMessage): void
|
||||||
|
dispose(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionHostOptions {
|
||||||
|
mode: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
apiProvider: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
workspacePath: string
|
||||||
|
extensionPath: string
|
||||||
|
verbose: boolean
|
||||||
|
debug: boolean
|
||||||
|
nonInteractive: boolean
|
||||||
|
ephemeral?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseExtensionHostOptions extends ExtensionHostOptions {
|
||||||
|
initialPrompt?: string
|
||||||
|
exitOnComplete?: boolean
|
||||||
|
onExtensionMessage: (msg: ExtensionMessage) => void
|
||||||
|
createExtensionHost: (options: ExtensionHostFactoryOptions) => ExtensionHostInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtensionHostFactoryOptions {
|
||||||
|
mode: string
|
||||||
|
reasoningEffort?: string
|
||||||
|
apiProvider: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
workspacePath: string
|
||||||
|
extensionPath: string
|
||||||
|
verbose: boolean
|
||||||
|
quiet: boolean
|
||||||
|
nonInteractive: boolean
|
||||||
|
disableOutput: boolean
|
||||||
|
ephemeral?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseExtensionHostReturn {
|
||||||
|
isReady: boolean
|
||||||
|
sendToExtension: ((msg: WebviewMessage) => void) | null
|
||||||
|
runTask: ((prompt: string) => Promise<void>) | null
|
||||||
|
cleanup: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage the extension host lifecycle.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Initialize the extension host
|
||||||
|
* - Set up event listeners for messages, task completion, and errors
|
||||||
|
* - Handle cleanup/disposal
|
||||||
|
* - Expose methods for sending messages and running tasks
|
||||||
|
*/
|
||||||
|
export function useExtensionHost({
|
||||||
|
initialPrompt,
|
||||||
|
mode,
|
||||||
|
reasoningEffort,
|
||||||
|
apiProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
workspacePath,
|
||||||
|
extensionPath,
|
||||||
|
verbose,
|
||||||
|
debug,
|
||||||
|
nonInteractive,
|
||||||
|
ephemeral,
|
||||||
|
exitOnComplete,
|
||||||
|
onExtensionMessage,
|
||||||
|
createExtensionHost,
|
||||||
|
}: UseExtensionHostOptions): UseExtensionHostReturn {
|
||||||
|
const { exit } = useApp()
|
||||||
|
const { addMessage, setComplete, setLoading, setHasStartedTask, setError } = useCLIStore()
|
||||||
|
|
||||||
|
const hostRef = useRef<ExtensionHostInterface | null>(null)
|
||||||
|
const isReadyRef = useRef(false)
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
const cleanup = useCallback(async () => {
|
||||||
|
if (hostRef.current) {
|
||||||
|
await hostRef.current.dispose()
|
||||||
|
hostRef.current = null
|
||||||
|
isReadyRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialize extension host
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
// Clear tool inspector log for fresh session
|
||||||
|
clearToolInspectorLog()
|
||||||
|
|
||||||
|
toolInspectorLog("session:start", {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
mode,
|
||||||
|
nonInteractive,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const host = createExtensionHost({
|
||||||
|
mode,
|
||||||
|
reasoningEffort: reasoningEffort === "unspecified" ? undefined : reasoningEffort,
|
||||||
|
apiProvider,
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
workspacePath,
|
||||||
|
extensionPath,
|
||||||
|
verbose: debug,
|
||||||
|
quiet: !verbose && !debug,
|
||||||
|
nonInteractive,
|
||||||
|
disableOutput: true,
|
||||||
|
ephemeral,
|
||||||
|
})
|
||||||
|
|
||||||
|
hostRef.current = host
|
||||||
|
isReadyRef.current = true
|
||||||
|
|
||||||
|
host.on("extensionWebviewMessage", onExtensionMessage)
|
||||||
|
|
||||||
|
host.on("taskComplete", async () => {
|
||||||
|
setComplete(true)
|
||||||
|
setLoading(false)
|
||||||
|
if (exitOnComplete) {
|
||||||
|
await cleanup()
|
||||||
|
exit()
|
||||||
|
setTimeout(() => process.exit(0), 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
host.on("taskError", (err: string) => {
|
||||||
|
setError(err)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
await host.activate()
|
||||||
|
|
||||||
|
// Request initial state from extension (triggers postStateToWebview which includes taskHistory)
|
||||||
|
host.sendToExtension({ type: "webviewDidLaunch" })
|
||||||
|
host.sendToExtension({ type: "requestCommands" })
|
||||||
|
host.sendToExtension({ type: "requestModes" })
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (initialPrompt) {
|
||||||
|
setHasStartedTask(true)
|
||||||
|
setLoading(true)
|
||||||
|
addMessage({
|
||||||
|
id: randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
content: initialPrompt,
|
||||||
|
})
|
||||||
|
await host.runTask(initialPrompt)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, []) // Run once on mount
|
||||||
|
|
||||||
|
// Stable sendToExtension - uses ref to always access current host
|
||||||
|
// This function reference never changes, preventing downstream useCallback/useMemo invalidations
|
||||||
|
const sendToExtension = useCallback((msg: WebviewMessage) => {
|
||||||
|
hostRef.current?.sendToExtension(msg)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Stable runTask - uses ref to always access current host
|
||||||
|
const runTask = useCallback((prompt: string): Promise<void> => {
|
||||||
|
if (!hostRef.current) {
|
||||||
|
return Promise.reject(new Error("Extension host not ready"))
|
||||||
|
}
|
||||||
|
return hostRef.current.runTask(prompt)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Memoized return object to prevent unnecessary re-renders in consumers
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
isReady: isReadyRef.current,
|
||||||
|
sendToExtension,
|
||||||
|
runTask,
|
||||||
|
cleanup,
|
||||||
|
}),
|
||||||
|
[sendToExtension, runTask, cleanup],
|
||||||
|
)
|
||||||
|
}
|
||||||
85
apps/cli/src/ui/hooks/useFocusManagement.ts
Normal file
85
apps/cli/src/ui/hooks/useFocusManagement.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||||
|
import type { PendingAsk } from "../types.js"
|
||||||
|
|
||||||
|
export interface UseFocusManagementOptions {
|
||||||
|
showApprovalPrompt: boolean
|
||||||
|
pendingAsk: PendingAsk | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseFocusManagementReturn {
|
||||||
|
/** Whether focus can be toggled between scroll and input areas */
|
||||||
|
canToggleFocus: boolean
|
||||||
|
/** Whether scroll area should capture keyboard input */
|
||||||
|
isScrollAreaActive: boolean
|
||||||
|
/** Whether input area is active (for visual focus indicator) */
|
||||||
|
isInputAreaActive: boolean
|
||||||
|
/** Manual focus override */
|
||||||
|
manualFocus: "scroll" | "input" | null
|
||||||
|
/** Set manual focus override */
|
||||||
|
setManualFocus: (focus: "scroll" | "input" | null) => void
|
||||||
|
/** Toggle focus between scroll and input */
|
||||||
|
toggleFocus: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage focus state between scroll area and input area.
|
||||||
|
*
|
||||||
|
* Focus can be toggled when text input is available (not showing approval prompt).
|
||||||
|
* The hook automatically resets manual focus when the view changes.
|
||||||
|
*/
|
||||||
|
export function useFocusManagement({
|
||||||
|
showApprovalPrompt,
|
||||||
|
pendingAsk,
|
||||||
|
}: UseFocusManagementOptions): UseFocusManagementReturn {
|
||||||
|
const { showCustomInput, manualFocus, setManualFocus } = useUIStateStore()
|
||||||
|
|
||||||
|
// Determine if we're in a mode where focus can be toggled (text input is available)
|
||||||
|
const canToggleFocus =
|
||||||
|
!showApprovalPrompt &&
|
||||||
|
(!pendingAsk || // Initial input or task complete or loading
|
||||||
|
pendingAsk.type === "followup" || // Followup question with suggestions or custom input
|
||||||
|
showCustomInput) // Custom input mode
|
||||||
|
|
||||||
|
// Determine if scroll area should capture keyboard input
|
||||||
|
const isScrollAreaActive: boolean =
|
||||||
|
manualFocus === "scroll" ? true : manualFocus === "input" ? false : Boolean(showApprovalPrompt)
|
||||||
|
|
||||||
|
// Determine if input area is active (for visual focus indicator)
|
||||||
|
const isInputAreaActive: boolean =
|
||||||
|
manualFocus === "input" ? true : manualFocus === "scroll" ? false : !showApprovalPrompt
|
||||||
|
|
||||||
|
// Reset manual focus when view changes (e.g., agent starts responding)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canToggleFocus) {
|
||||||
|
setManualFocus(null)
|
||||||
|
}
|
||||||
|
}, [canToggleFocus, setManualFocus])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle focus between scroll and input areas
|
||||||
|
*/
|
||||||
|
const toggleFocus = () => {
|
||||||
|
if (!canToggleFocus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = manualFocus
|
||||||
|
if (prev === "scroll") {
|
||||||
|
setManualFocus("input")
|
||||||
|
} else if (prev === "input") {
|
||||||
|
setManualFocus("scroll")
|
||||||
|
} else {
|
||||||
|
setManualFocus(isScrollAreaActive ? "input" : "scroll")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canToggleFocus,
|
||||||
|
isScrollAreaActive,
|
||||||
|
isInputAreaActive,
|
||||||
|
manualFocus,
|
||||||
|
setManualFocus,
|
||||||
|
toggleFocus,
|
||||||
|
}
|
||||||
|
}
|
||||||
112
apps/cli/src/ui/hooks/useFollowupCountdown.ts
Normal file
112
apps/cli/src/ui/hooks/useFollowupCountdown.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { FOLLOWUP_TIMEOUT_SECONDS } from "../../constants.js"
|
||||||
|
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||||
|
import type { PendingAsk } from "../types.js"
|
||||||
|
|
||||||
|
export interface UseFollowupCountdownOptions {
|
||||||
|
pendingAsk: PendingAsk | null
|
||||||
|
onAutoSubmit: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage auto-accept countdown timer for followup questions with suggestions.
|
||||||
|
*
|
||||||
|
* When a followup question appears with suggestions (and not in custom input mode),
|
||||||
|
* starts a countdown timer that auto-submits the first suggestion when it reaches zero.
|
||||||
|
*
|
||||||
|
* The countdown can be canceled by:
|
||||||
|
* - User navigating with arrow keys
|
||||||
|
* - User switching to custom input mode
|
||||||
|
* - Followup question changing/disappearing
|
||||||
|
*/
|
||||||
|
export function useFollowupCountdown({ pendingAsk, onAutoSubmit }: UseFollowupCountdownOptions) {
|
||||||
|
const { showCustomInput, countdownSeconds, setCountdownSeconds } = useUIStateStore()
|
||||||
|
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Use ref for onAutoSubmit to avoid stale closure issues without needing it in dependencies
|
||||||
|
const onAutoSubmitRef = useRef(onAutoSubmit)
|
||||||
|
useEffect(() => {
|
||||||
|
onAutoSubmitRef.current = onAutoSubmit
|
||||||
|
}, [onAutoSubmit])
|
||||||
|
|
||||||
|
// Cleanup interval on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Start countdown when a followup question with suggestions appears
|
||||||
|
useEffect(() => {
|
||||||
|
// Clear any existing countdown
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current)
|
||||||
|
countdownIntervalRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only start countdown for followup questions with suggestions (not custom input mode)
|
||||||
|
if (
|
||||||
|
pendingAsk?.type === "followup" &&
|
||||||
|
pendingAsk.suggestions &&
|
||||||
|
pendingAsk.suggestions.length > 0 &&
|
||||||
|
!showCustomInput
|
||||||
|
) {
|
||||||
|
// Start countdown
|
||||||
|
setCountdownSeconds(FOLLOWUP_TIMEOUT_SECONDS)
|
||||||
|
|
||||||
|
countdownIntervalRef.current = setInterval(() => {
|
||||||
|
const currentSeconds = useUIStateStore.getState().countdownSeconds
|
||||||
|
if (currentSeconds === null || currentSeconds <= 1) {
|
||||||
|
// Time's up! Auto-select first option
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current)
|
||||||
|
countdownIntervalRef.current = null
|
||||||
|
}
|
||||||
|
setCountdownSeconds(null)
|
||||||
|
// Auto-submit the first suggestion
|
||||||
|
if (pendingAsk?.suggestions && pendingAsk.suggestions.length > 0) {
|
||||||
|
const firstSuggestion = pendingAsk.suggestions[0]
|
||||||
|
if (firstSuggestion) {
|
||||||
|
onAutoSubmitRef.current(firstSuggestion.answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCountdownSeconds(currentSeconds - 1)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
// Only set to null if not already null to prevent unnecessary state updates
|
||||||
|
// This is critical to avoid infinite render loops
|
||||||
|
if (countdownSeconds !== null) {
|
||||||
|
setCountdownSeconds(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current)
|
||||||
|
countdownIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: countdownSeconds is intentionally NOT in deps - we only read it to avoid
|
||||||
|
// unnecessary state updates, not to react to its changes
|
||||||
|
}, [pendingAsk?.id, pendingAsk?.type, showCustomInput, setCountdownSeconds])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the countdown timer (called when user interacts with the menu)
|
||||||
|
*/
|
||||||
|
const cancelCountdown = () => {
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current)
|
||||||
|
countdownIntervalRef.current = null
|
||||||
|
}
|
||||||
|
setCountdownSeconds(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
countdownSeconds,
|
||||||
|
cancelCountdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
170
apps/cli/src/ui/hooks/useGlobalInput.ts
Normal file
170
apps/cli/src/ui/hooks/useGlobalInput.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
import { useInput } from "ink"
|
||||||
|
import type { WebviewMessage } from "@roo-code/types"
|
||||||
|
|
||||||
|
import { matchesGlobalSequence } from "../../utils/globalInputSequences.js"
|
||||||
|
import type { ModeResult } from "../components/autocomplete/index.js"
|
||||||
|
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||||
|
import { useCLIStore } from "../store.js"
|
||||||
|
|
||||||
|
export interface UseGlobalInputOptions {
|
||||||
|
canToggleFocus: boolean
|
||||||
|
isScrollAreaActive: boolean
|
||||||
|
pickerIsOpen: boolean
|
||||||
|
availableModes: ModeResult[]
|
||||||
|
currentMode: string | null
|
||||||
|
mode: string
|
||||||
|
sendToExtension: ((msg: WebviewMessage) => void) | null
|
||||||
|
showInfo: (msg: string, duration?: number) => void
|
||||||
|
exit: () => void
|
||||||
|
cleanup: () => Promise<void>
|
||||||
|
toggleFocus: () => void
|
||||||
|
closePicker: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle global keyboard shortcuts.
|
||||||
|
*
|
||||||
|
* Shortcuts:
|
||||||
|
* - Ctrl+C: Double-press to exit
|
||||||
|
* - Tab: Toggle focus between scroll area and input
|
||||||
|
* - Ctrl+M: Cycle through available modes
|
||||||
|
* - Ctrl+T: Toggle TODO list viewer
|
||||||
|
* - Escape: Cancel task (when loading) or close TODO viewer
|
||||||
|
*/
|
||||||
|
export function useGlobalInput({
|
||||||
|
canToggleFocus,
|
||||||
|
isScrollAreaActive: _isScrollAreaActive,
|
||||||
|
pickerIsOpen,
|
||||||
|
availableModes,
|
||||||
|
currentMode,
|
||||||
|
mode,
|
||||||
|
sendToExtension,
|
||||||
|
showInfo,
|
||||||
|
exit,
|
||||||
|
cleanup,
|
||||||
|
toggleFocus,
|
||||||
|
closePicker,
|
||||||
|
}: UseGlobalInputOptions): void {
|
||||||
|
const { isLoading, currentTodos } = useCLIStore()
|
||||||
|
const {
|
||||||
|
showTodoViewer,
|
||||||
|
setShowTodoViewer,
|
||||||
|
showExitHint: _showExitHint,
|
||||||
|
setShowExitHint,
|
||||||
|
pendingExit,
|
||||||
|
setPendingExit,
|
||||||
|
} = useUIStateStore()
|
||||||
|
|
||||||
|
// Track Ctrl+C presses for "press again to exit" behavior
|
||||||
|
const exitHintTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (exitHintTimeout.current) {
|
||||||
|
clearTimeout(exitHintTimeout.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle global keyboard shortcuts
|
||||||
|
useInput((input, key) => {
|
||||||
|
// Tab to toggle focus between scroll area and input (only when input is available)
|
||||||
|
if (key.tab && canToggleFocus && !pickerIsOpen) {
|
||||||
|
toggleFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+M to cycle through modes (only when not loading and we have available modes)
|
||||||
|
// Uses centralized global input sequence detection
|
||||||
|
if (matchesGlobalSequence(input, key, "ctrl-m")) {
|
||||||
|
// Don't allow mode switching while a task is in progress (loading)
|
||||||
|
if (isLoading) {
|
||||||
|
showInfo("Cannot switch modes while task is in progress", 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need at least 2 modes to cycle
|
||||||
|
if (availableModes.length < 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current mode index
|
||||||
|
const currentModeSlug = currentMode || mode
|
||||||
|
const currentIndex = availableModes.findIndex((m) => m.slug === currentModeSlug)
|
||||||
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % availableModes.length
|
||||||
|
const nextMode = availableModes[nextIndex]
|
||||||
|
|
||||||
|
if (nextMode && sendToExtension) {
|
||||||
|
// Send mode change to extension
|
||||||
|
sendToExtension({ type: "switchMode", mode: nextMode.slug })
|
||||||
|
// Show toast notification with the mode name
|
||||||
|
showInfo(`Switched to ${nextMode.name}`, 2000)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+T to toggle TODO list viewer
|
||||||
|
if (matchesGlobalSequence(input, key, "ctrl-t")) {
|
||||||
|
// Close picker if open
|
||||||
|
if (pickerIsOpen) {
|
||||||
|
closePicker()
|
||||||
|
}
|
||||||
|
// Toggle TODO viewer
|
||||||
|
setShowTodoViewer(!showTodoViewer)
|
||||||
|
if (!showTodoViewer && currentTodos.length === 0) {
|
||||||
|
showInfo("No TODO list available", 2000)
|
||||||
|
setShowTodoViewer(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key to close TODO viewer
|
||||||
|
if (key.escape && showTodoViewer) {
|
||||||
|
setShowTodoViewer(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key to cancel/pause task when loading (streaming)
|
||||||
|
if (key.escape && isLoading && sendToExtension) {
|
||||||
|
// If picker is open, let the picker handle escape first
|
||||||
|
if (pickerIsOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Send cancel message to extension (same as webview-ui Cancel button)
|
||||||
|
sendToExtension({ type: "cancelTask" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+C to exit
|
||||||
|
if (key.ctrl && input === "c") {
|
||||||
|
// If picker is open, close it first
|
||||||
|
if (pickerIsOpen) {
|
||||||
|
closePicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingExit) {
|
||||||
|
// Second press - exit immediately
|
||||||
|
if (exitHintTimeout.current) {
|
||||||
|
clearTimeout(exitHintTimeout.current)
|
||||||
|
}
|
||||||
|
cleanup().finally(() => {
|
||||||
|
exit()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// First press - show hint and wait for second press
|
||||||
|
setPendingExit(true)
|
||||||
|
setShowExitHint(true)
|
||||||
|
|
||||||
|
exitHintTimeout.current = setTimeout(() => {
|
||||||
|
setPendingExit(false)
|
||||||
|
setShowExitHint(false)
|
||||||
|
exitHintTimeout.current = null
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
127
apps/cli/src/ui/hooks/useInputHistory.ts
Normal file
127
apps/cli/src/ui/hooks/useInputHistory.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
437
apps/cli/src/ui/hooks/useMessageHandlers.ts
Normal file
437
apps/cli/src/ui/hooks/useMessageHandlers.ts
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
import { useCallback, useRef } from "react"
|
||||||
|
import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
|
||||||
|
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils"
|
||||||
|
|
||||||
|
import { toolInspectorLog } from "../../utils/toolInspectorLogger.js"
|
||||||
|
import type { TUIMessage, ToolData } from "../types.js"
|
||||||
|
import type { FileResult, SlashCommandResult, ModeResult } from "../components/autocomplete/index.js"
|
||||||
|
import { useCLIStore } from "../store.js"
|
||||||
|
import {
|
||||||
|
extractToolData,
|
||||||
|
formatToolOutput,
|
||||||
|
formatToolAskMessage,
|
||||||
|
parseTodosFromToolInfo,
|
||||||
|
} from "../utils/toolDataUtils.js"
|
||||||
|
|
||||||
|
export interface UseMessageHandlersOptions {
|
||||||
|
verbose: boolean
|
||||||
|
nonInteractive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseMessageHandlersReturn {
|
||||||
|
handleExtensionMessage: (msg: ExtensionMessage) => void
|
||||||
|
seenMessageIds: React.MutableRefObject<Set<string>>
|
||||||
|
pendingCommandRef: React.MutableRefObject<string | null>
|
||||||
|
firstTextMessageSkipped: React.MutableRefObject<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle messages from the extension.
|
||||||
|
*
|
||||||
|
* Processes three types of messages:
|
||||||
|
* 1. "say" messages - Information from the agent (text, tool output, reasoning)
|
||||||
|
* 2. "ask" messages - Requests for user input (approvals, followup questions)
|
||||||
|
* 3. Extension state updates - Mode changes, task history, file search results
|
||||||
|
*
|
||||||
|
* Transforms ClineMessage format to TUIMessage format and updates the store.
|
||||||
|
*/
|
||||||
|
export function useMessageHandlers({ verbose, nonInteractive }: UseMessageHandlersOptions): UseMessageHandlersReturn {
|
||||||
|
const {
|
||||||
|
addMessage,
|
||||||
|
setPendingAsk,
|
||||||
|
setComplete,
|
||||||
|
setLoading,
|
||||||
|
setHasStartedTask,
|
||||||
|
setFileSearchResults,
|
||||||
|
setAllSlashCommands,
|
||||||
|
setAvailableModes,
|
||||||
|
setCurrentMode,
|
||||||
|
setTokenUsage,
|
||||||
|
setRouterModels,
|
||||||
|
setTaskHistory,
|
||||||
|
currentTodos,
|
||||||
|
setTodos,
|
||||||
|
} = useCLIStore()
|
||||||
|
|
||||||
|
// Track seen message timestamps to filter duplicates and the prompt echo
|
||||||
|
const seenMessageIds = useRef<Set<string>>(new Set())
|
||||||
|
const firstTextMessageSkipped = useRef(false)
|
||||||
|
|
||||||
|
// Track pending command for injecting into command_output toolData
|
||||||
|
const pendingCommandRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map extension "say" messages to TUI messages
|
||||||
|
*/
|
||||||
|
const handleSayMessage = useCallback(
|
||||||
|
(ts: number, say: ClineSay, text: string, partial: boolean) => {
|
||||||
|
const messageId = ts.toString()
|
||||||
|
const isResuming = useCLIStore.getState().isResumingTask
|
||||||
|
|
||||||
|
if (say === "checkpoint_saved") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (say === "api_req_started" && !verbose) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (say === "user_feedback") {
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip first text message ONLY for new tasks, not resumed tasks
|
||||||
|
// When resuming, we want to show all historical messages including the first one
|
||||||
|
if (say === "text" && !firstTextMessageSkipped.current && !isResuming) {
|
||||||
|
firstTextMessageSkipped.current = true
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenMessageIds.current.has(messageId) && !partial) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let role: TUIMessage["role"] = "assistant"
|
||||||
|
let toolName: string | undefined
|
||||||
|
let toolDisplayName: string | undefined
|
||||||
|
let toolDisplayOutput: string | undefined
|
||||||
|
let toolData: ToolData | undefined
|
||||||
|
|
||||||
|
if (say === "command_output") {
|
||||||
|
role = "tool"
|
||||||
|
toolName = "execute_command"
|
||||||
|
toolDisplayName = "bash"
|
||||||
|
toolDisplayOutput = text
|
||||||
|
const trackedCommand = pendingCommandRef.current
|
||||||
|
toolInspectorLog("say:command_output", { ts, trackedCommand, outputLength: text?.length })
|
||||||
|
toolData = { tool: "execute_command", command: trackedCommand || undefined, output: text }
|
||||||
|
pendingCommandRef.current = null
|
||||||
|
} else if (say === "reasoning") {
|
||||||
|
role = "thinking"
|
||||||
|
}
|
||||||
|
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
role,
|
||||||
|
content: text || "",
|
||||||
|
toolName,
|
||||||
|
toolDisplayName,
|
||||||
|
toolDisplayOutput,
|
||||||
|
partial,
|
||||||
|
originalType: say,
|
||||||
|
toolData,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[addMessage, verbose],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle extension "ask" messages
|
||||||
|
*/
|
||||||
|
const handleAskMessage = useCallback(
|
||||||
|
(ts: number, ask: ClineAsk, text: string, partial: boolean) => {
|
||||||
|
const messageId = ts.toString()
|
||||||
|
|
||||||
|
if (partial) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenMessageIds.current.has(messageId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ask === "command_output") {
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle resume_task and resume_completed_task - stop loading and show text input
|
||||||
|
// Do not set pendingAsk - just stop loading so user sees normal input to type new message
|
||||||
|
if (ask === "resume_task" || ask === "resume_completed_task") {
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
setLoading(false)
|
||||||
|
// Mark that a task has been started so subsequent messages continue the task
|
||||||
|
// (instead of starting a brand new task via runTask)
|
||||||
|
setHasStartedTask(true)
|
||||||
|
// Clear the resuming flag since we're now ready for interaction
|
||||||
|
// Historical messages should already be displayed from state processing
|
||||||
|
useCLIStore.getState().setIsResumingTask(false)
|
||||||
|
// Do not set pendingAsk - let the normal text input appear
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ask === "completion_result") {
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
setComplete(true)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
// Parse the completion result and add a message for CompletionTool to render
|
||||||
|
try {
|
||||||
|
const completionInfo = JSON.parse(text) as Record<string, unknown>
|
||||||
|
const toolData: ToolData = {
|
||||||
|
tool: "attempt_completion",
|
||||||
|
result: completionInfo.result as string | undefined,
|
||||||
|
content: completionInfo.result as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
role: "tool",
|
||||||
|
content: text,
|
||||||
|
toolName: "attempt_completion",
|
||||||
|
toolDisplayName: "Task Complete",
|
||||||
|
toolDisplayOutput: formatToolOutput({ tool: "attempt_completion", ...completionInfo }),
|
||||||
|
originalType: ask,
|
||||||
|
toolData,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, still add a basic completion message
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
role: "tool",
|
||||||
|
content: text || "Task completed",
|
||||||
|
toolName: "attempt_completion",
|
||||||
|
toolDisplayName: "Task Complete",
|
||||||
|
toolDisplayOutput: "✅ Task completed",
|
||||||
|
originalType: ask,
|
||||||
|
toolData: {
|
||||||
|
tool: "attempt_completion",
|
||||||
|
content: text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pending command BEFORE nonInteractive handling
|
||||||
|
// This ensures we capture the command text for later injection into command_output toolData
|
||||||
|
if (ask === "command") {
|
||||||
|
toolInspectorLog("ask:command:tracking", { ts, text })
|
||||||
|
pendingCommandRef.current = text
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonInteractive && ask !== "followup") {
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
|
||||||
|
if (ask === "tool") {
|
||||||
|
let toolName: string | undefined
|
||||||
|
let toolDisplayName: string | undefined
|
||||||
|
let toolDisplayOutput: string | undefined
|
||||||
|
let formattedContent = text || ""
|
||||||
|
let toolData: ToolData | undefined
|
||||||
|
let todos: TodoItem[] | undefined
|
||||||
|
let previousTodos: TodoItem[] | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||||
|
|
||||||
|
// Log tool payload for inspection (nonInteractive ask)
|
||||||
|
toolInspectorLog("ask:tool:nonInteractive", {
|
||||||
|
ts,
|
||||||
|
rawText: text,
|
||||||
|
parsedToolInfo: toolInfo,
|
||||||
|
partial,
|
||||||
|
})
|
||||||
|
|
||||||
|
toolName = toolInfo.tool as string
|
||||||
|
toolDisplayName = toolInfo.tool as string
|
||||||
|
toolDisplayOutput = formatToolOutput(toolInfo)
|
||||||
|
formattedContent = formatToolAskMessage(toolInfo)
|
||||||
|
// Extract structured toolData for rich rendering
|
||||||
|
toolData = extractToolData(toolInfo)
|
||||||
|
|
||||||
|
// Special handling for update_todo_list tool - extract todos
|
||||||
|
if (toolName === "update_todo_list" || toolName === "updateTodoList") {
|
||||||
|
const parsedTodos = parseTodosFromToolInfo(toolInfo)
|
||||||
|
if (parsedTodos && parsedTodos.length > 0) {
|
||||||
|
todos = parsedTodos
|
||||||
|
// Capture previous todos before updating global state
|
||||||
|
previousTodos = [...currentTodos]
|
||||||
|
setTodos(parsedTodos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use raw text if not valid JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
role: "tool",
|
||||||
|
content: formattedContent,
|
||||||
|
toolName,
|
||||||
|
toolDisplayName,
|
||||||
|
toolDisplayOutput,
|
||||||
|
originalType: ask,
|
||||||
|
toolData,
|
||||||
|
todos,
|
||||||
|
previousTodos,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addMessage({
|
||||||
|
id: messageId,
|
||||||
|
role: "assistant",
|
||||||
|
content: text || "",
|
||||||
|
originalType: ask,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let suggestions: Array<{ answer: string; mode?: string | null }> | undefined
|
||||||
|
let questionText = text
|
||||||
|
|
||||||
|
if (ask === "followup") {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
questionText = data.question || text
|
||||||
|
suggestions = Array.isArray(data.suggest) ? data.suggest : undefined
|
||||||
|
} catch {
|
||||||
|
// Use raw text
|
||||||
|
}
|
||||||
|
} else if (ask === "tool") {
|
||||||
|
try {
|
||||||
|
const toolInfo = JSON.parse(text) as Record<string, unknown>
|
||||||
|
|
||||||
|
// Log tool payload for inspection (interactive ask)
|
||||||
|
toolInspectorLog("ask:tool:interactive", {
|
||||||
|
ts,
|
||||||
|
rawText: text,
|
||||||
|
parsedToolInfo: toolInfo,
|
||||||
|
partial,
|
||||||
|
})
|
||||||
|
|
||||||
|
questionText = formatToolAskMessage(toolInfo)
|
||||||
|
} catch {
|
||||||
|
// Use raw text if not valid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Note: ask === "command" is handled above before the nonInteractive block
|
||||||
|
|
||||||
|
seenMessageIds.current.add(messageId)
|
||||||
|
|
||||||
|
setPendingAsk({
|
||||||
|
id: messageId,
|
||||||
|
type: ask,
|
||||||
|
content: questionText,
|
||||||
|
suggestions,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[addMessage, setPendingAsk, setComplete, setLoading, setHasStartedTask, nonInteractive, currentTodos, setTodos],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all extension messages
|
||||||
|
*/
|
||||||
|
const handleExtensionMessage = useCallback(
|
||||||
|
(msg: ExtensionMessage) => {
|
||||||
|
if (msg.type === "state") {
|
||||||
|
const state = msg.state
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and update current mode from state
|
||||||
|
const newMode = state.mode
|
||||||
|
|
||||||
|
if (newMode) {
|
||||||
|
setCurrentMode(newMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and update task history from state
|
||||||
|
const newTaskHistory = state.taskHistory
|
||||||
|
|
||||||
|
if (newTaskHistory && Array.isArray(newTaskHistory)) {
|
||||||
|
setTaskHistory(newTaskHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clineMessages = state.clineMessages
|
||||||
|
|
||||||
|
if (clineMessages) {
|
||||||
|
for (const clineMsg of clineMessages) {
|
||||||
|
const ts = clineMsg.ts
|
||||||
|
const type = clineMsg.type
|
||||||
|
const say = clineMsg.say
|
||||||
|
const ask = clineMsg.ask
|
||||||
|
const text = clineMsg.text || ""
|
||||||
|
const partial = clineMsg.partial || false
|
||||||
|
|
||||||
|
if (type === "say" && say) {
|
||||||
|
handleSayMessage(ts, say, text, partial)
|
||||||
|
} else if (type === "ask" && ask) {
|
||||||
|
handleAskMessage(ts, ask, text, partial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute token usage metrics from clineMessages
|
||||||
|
// Skip first message (task prompt) as per webview UI pattern
|
||||||
|
if (clineMessages.length > 1) {
|
||||||
|
const processed = consolidateApiRequests(
|
||||||
|
consolidateCommands(clineMessages.slice(1) as ClineMessage[]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const metrics = consolidateTokenUsage(processed)
|
||||||
|
setTokenUsage(metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After processing state, clear the resuming flag if it was set
|
||||||
|
// This ensures the flag is cleared even if no resume_task ask message is received
|
||||||
|
if (useCLIStore.getState().isResumingTask) {
|
||||||
|
useCLIStore.getState().setIsResumingTask(false)
|
||||||
|
}
|
||||||
|
} else if (msg.type === "messageUpdated") {
|
||||||
|
const clineMessage = msg.clineMessage
|
||||||
|
|
||||||
|
if (!clineMessage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = clineMessage.ts
|
||||||
|
const type = clineMessage.type
|
||||||
|
const say = clineMessage.say
|
||||||
|
const ask = clineMessage.ask
|
||||||
|
const text = clineMessage.text || ""
|
||||||
|
const partial = clineMessage.partial || false
|
||||||
|
|
||||||
|
if (type === "say" && say) {
|
||||||
|
handleSayMessage(ts, say, text, partial)
|
||||||
|
} else if (type === "ask" && ask) {
|
||||||
|
handleAskMessage(ts, ask, text, partial)
|
||||||
|
}
|
||||||
|
} else if (msg.type === "fileSearchResults") {
|
||||||
|
setFileSearchResults((msg.results as FileResult[]) || [])
|
||||||
|
} else if (msg.type === "commands") {
|
||||||
|
setAllSlashCommands((msg.commands as SlashCommandResult[]) || [])
|
||||||
|
} else if (msg.type === "modes") {
|
||||||
|
setAvailableModes((msg.modes as ModeResult[]) || [])
|
||||||
|
} else if (msg.type === "routerModels") {
|
||||||
|
if (msg.routerModels) {
|
||||||
|
setRouterModels(msg.routerModels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handleSayMessage,
|
||||||
|
handleAskMessage,
|
||||||
|
setFileSearchResults,
|
||||||
|
setAllSlashCommands,
|
||||||
|
setAvailableModes,
|
||||||
|
setCurrentMode,
|
||||||
|
setTokenUsage,
|
||||||
|
setRouterModels,
|
||||||
|
setTaskHistory,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleExtensionMessage,
|
||||||
|
seenMessageIds,
|
||||||
|
pendingCommandRef,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
171
apps/cli/src/ui/hooks/usePickerHandlers.ts
Normal file
171
apps/cli/src/ui/hooks/usePickerHandlers.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import type { WebviewMessage } from "@roo-code/types"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AutocompletePickerState,
|
||||||
|
AutocompleteInputHandle,
|
||||||
|
ModeResult,
|
||||||
|
HistoryResult,
|
||||||
|
} from "../components/autocomplete/index.js"
|
||||||
|
import { useCLIStore } from "../store.js"
|
||||||
|
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||||
|
|
||||||
|
export interface UsePickerHandlersOptions {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
autocompleteRef: React.RefObject<AutocompleteInputHandle<any>>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
followupAutocompleteRef: React.RefObject<AutocompleteInputHandle<any>>
|
||||||
|
sendToExtension: ((msg: WebviewMessage) => void) | null
|
||||||
|
showInfo: (msg: string, duration?: number) => void
|
||||||
|
seenMessageIds: React.MutableRefObject<Set<string>>
|
||||||
|
firstTextMessageSkipped: React.MutableRefObject<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePickerHandlersReturn {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
handlePickerStateChange: (state: AutocompletePickerState<any>) => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
handlePickerSelect: (item: any) => void
|
||||||
|
handlePickerClose: () => void
|
||||||
|
handlePickerIndexChange: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle autocomplete picker interactions.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Handle picker state changes from AutocompleteInput
|
||||||
|
* - Handle item selection (special handling for modes and history items)
|
||||||
|
* - Handle mode switching via picker
|
||||||
|
* - Handle task switching via history picker
|
||||||
|
* - Handle picker close and index change
|
||||||
|
*/
|
||||||
|
export function usePickerHandlers({
|
||||||
|
autocompleteRef,
|
||||||
|
followupAutocompleteRef,
|
||||||
|
sendToExtension,
|
||||||
|
showInfo,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
}: UsePickerHandlersOptions): UsePickerHandlersReturn {
|
||||||
|
const { isLoading, currentTaskId, setCurrentTaskId } = useCLIStore()
|
||||||
|
const { pickerState, setPickerState } = useUIStateStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle picker state changes from AutocompleteInput
|
||||||
|
*/
|
||||||
|
const handlePickerStateChange = useCallback(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(state: AutocompletePickerState<any>) => {
|
||||||
|
setPickerState(state)
|
||||||
|
},
|
||||||
|
[setPickerState],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle item selection from external PickerSelect
|
||||||
|
*/
|
||||||
|
const handlePickerSelect = useCallback(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(item: any) => {
|
||||||
|
// Check if this is a mode selection
|
||||||
|
if (pickerState.activeTrigger?.id === "mode" && item && typeof item === "object" && "slug" in item) {
|
||||||
|
const modeItem = item as ModeResult
|
||||||
|
|
||||||
|
// Send mode change message to extension
|
||||||
|
if (sendToExtension) {
|
||||||
|
sendToExtension({ type: "switchMode", mode: modeItem.slug })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the picker
|
||||||
|
autocompleteRef.current?.closePicker()
|
||||||
|
followupAutocompleteRef.current?.closePicker()
|
||||||
|
}
|
||||||
|
// Check if this is a history item selection
|
||||||
|
else if (pickerState.activeTrigger?.id === "history" && item && typeof item === "object" && "id" in item) {
|
||||||
|
const historyItem = item as HistoryResult
|
||||||
|
|
||||||
|
// Don't allow task switching while a task is in progress (loading)
|
||||||
|
if (isLoading) {
|
||||||
|
showInfo("Cannot switch tasks while task is in progress", 2000)
|
||||||
|
// Close the picker
|
||||||
|
autocompleteRef.current?.closePicker()
|
||||||
|
followupAutocompleteRef.current?.closePicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If selecting the same task that's already loaded, just close the picker
|
||||||
|
if (historyItem.id === currentTaskId) {
|
||||||
|
autocompleteRef.current?.closePicker()
|
||||||
|
followupAutocompleteRef.current?.closePicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send showTaskWithId message to extension to resume the task
|
||||||
|
if (sendToExtension) {
|
||||||
|
// Use selective reset that preserves global state (taskHistory, modes, commands)
|
||||||
|
useCLIStore.getState().resetForTaskSwitch()
|
||||||
|
// Set the resuming flag so message handlers know we're resuming
|
||||||
|
// This prevents skipping the first text message (which is historical)
|
||||||
|
useCLIStore.getState().setIsResumingTask(true)
|
||||||
|
// Track which task we're switching to
|
||||||
|
setCurrentTaskId(historyItem.id)
|
||||||
|
// Reset refs to avoid stale state across task switches
|
||||||
|
seenMessageIds.current.clear()
|
||||||
|
firstTextMessageSkipped.current = false
|
||||||
|
|
||||||
|
// Send message to resume the selected task
|
||||||
|
// This triggers createTaskWithHistoryItem -> postStateToWebview
|
||||||
|
// which includes clineMessages and handles mode restoration
|
||||||
|
sendToExtension({ type: "showTaskWithId", text: historyItem.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the picker
|
||||||
|
autocompleteRef.current?.closePicker()
|
||||||
|
followupAutocompleteRef.current?.closePicker()
|
||||||
|
} else {
|
||||||
|
// Handle other item selections normally
|
||||||
|
autocompleteRef.current?.handleItemSelect(item)
|
||||||
|
followupAutocompleteRef.current?.handleItemSelect(item)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
pickerState.activeTrigger,
|
||||||
|
isLoading,
|
||||||
|
showInfo,
|
||||||
|
currentTaskId,
|
||||||
|
setCurrentTaskId,
|
||||||
|
sendToExtension,
|
||||||
|
autocompleteRef,
|
||||||
|
followupAutocompleteRef,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle picker close from external PickerSelect
|
||||||
|
*/
|
||||||
|
const handlePickerClose = useCallback(() => {
|
||||||
|
autocompleteRef.current?.closePicker()
|
||||||
|
followupAutocompleteRef.current?.closePicker()
|
||||||
|
}, [autocompleteRef, followupAutocompleteRef])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle picker index change from external PickerSelect
|
||||||
|
*/
|
||||||
|
const handlePickerIndexChange = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
autocompleteRef.current?.handleIndexChange(index)
|
||||||
|
followupAutocompleteRef.current?.handleIndexChange(index)
|
||||||
|
},
|
||||||
|
[autocompleteRef, followupAutocompleteRef],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handlePickerStateChange,
|
||||||
|
handlePickerSelect,
|
||||||
|
handlePickerClose,
|
||||||
|
handlePickerIndexChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/cli/src/ui/hooks/useTaskSubmit.ts
Normal file
181
apps/cli/src/ui/hooks/useTaskSubmit.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { useCallback } from "react"
|
||||||
|
import { randomUUID } from "crypto"
|
||||||
|
import type { WebviewMessage } from "@roo-code/types"
|
||||||
|
|
||||||
|
import { getGlobalCommand } from "../../utils/globalCommands.js"
|
||||||
|
import { useCLIStore } from "../store.js"
|
||||||
|
import { useUIStateStore } from "../stores/uiStateStore.js"
|
||||||
|
|
||||||
|
export interface UseTaskSubmitOptions {
|
||||||
|
sendToExtension: ((msg: WebviewMessage) => void) | null
|
||||||
|
runTask: ((prompt: string) => Promise<void>) | null
|
||||||
|
seenMessageIds: React.MutableRefObject<Set<string>>
|
||||||
|
firstTextMessageSkipped: React.MutableRefObject<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseTaskSubmitReturn {
|
||||||
|
handleSubmit: (text: string) => Promise<void>
|
||||||
|
handleApprove: () => void
|
||||||
|
handleReject: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle task submission, user responses, and approvals.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Process user message submissions
|
||||||
|
* - Detect and handle global commands (like /new)
|
||||||
|
* - Handle pending ask responses
|
||||||
|
* - Start new tasks or continue existing ones
|
||||||
|
* - Handle Y/N approval responses
|
||||||
|
*/
|
||||||
|
export function useTaskSubmit({
|
||||||
|
sendToExtension,
|
||||||
|
runTask,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
}: UseTaskSubmitOptions): UseTaskSubmitReturn {
|
||||||
|
const {
|
||||||
|
pendingAsk,
|
||||||
|
hasStartedTask,
|
||||||
|
isComplete,
|
||||||
|
addMessage,
|
||||||
|
setPendingAsk,
|
||||||
|
setHasStartedTask,
|
||||||
|
setLoading,
|
||||||
|
setComplete,
|
||||||
|
setError,
|
||||||
|
} = useCLIStore()
|
||||||
|
|
||||||
|
const { setShowCustomInput, setIsTransitioningToCustomInput } = useUIStateStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle user text submission (from input or followup question)
|
||||||
|
*/
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!sendToExtension || !text.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedText = text.trim()
|
||||||
|
|
||||||
|
if (trimmedText === "__CUSTOM__") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CLI global action commands (e.g., /new)
|
||||||
|
if (trimmedText.startsWith("/")) {
|
||||||
|
const commandMatch = trimmedText.match(/^\/(\w+)(?:\s|$)/)
|
||||||
|
|
||||||
|
if (commandMatch && commandMatch[1]) {
|
||||||
|
const globalCommand = getGlobalCommand(commandMatch[1])
|
||||||
|
|
||||||
|
if (globalCommand?.action === "clearTask") {
|
||||||
|
// Reset CLI state and send clearTask to extension
|
||||||
|
useCLIStore.getState().reset()
|
||||||
|
// Reset component-level refs to avoid stale message tracking
|
||||||
|
seenMessageIds.current.clear()
|
||||||
|
firstTextMessageSkipped.current = false
|
||||||
|
sendToExtension({ type: "clearTask" })
|
||||||
|
// Re-request state, commands and modes since reset() cleared them
|
||||||
|
sendToExtension({ type: "webviewDidLaunch" })
|
||||||
|
sendToExtension({ type: "requestCommands" })
|
||||||
|
sendToExtension({ type: "requestModes" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingAsk) {
|
||||||
|
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
|
||||||
|
|
||||||
|
sendToExtension({
|
||||||
|
type: "askResponse",
|
||||||
|
askResponse: "messageResponse",
|
||||||
|
text: trimmedText,
|
||||||
|
})
|
||||||
|
|
||||||
|
setPendingAsk(null)
|
||||||
|
setShowCustomInput(false)
|
||||||
|
setIsTransitioningToCustomInput(false)
|
||||||
|
setLoading(true)
|
||||||
|
} else if (!hasStartedTask) {
|
||||||
|
setHasStartedTask(true)
|
||||||
|
setLoading(true)
|
||||||
|
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (runTask) {
|
||||||
|
await runTask(trimmedText)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isComplete) {
|
||||||
|
setComplete(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
addMessage({ id: randomUUID(), role: "user", content: trimmedText })
|
||||||
|
|
||||||
|
sendToExtension({
|
||||||
|
type: "askResponse",
|
||||||
|
askResponse: "messageResponse",
|
||||||
|
text: trimmedText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
sendToExtension,
|
||||||
|
runTask,
|
||||||
|
pendingAsk,
|
||||||
|
hasStartedTask,
|
||||||
|
isComplete,
|
||||||
|
addMessage,
|
||||||
|
setPendingAsk,
|
||||||
|
setHasStartedTask,
|
||||||
|
setLoading,
|
||||||
|
setComplete,
|
||||||
|
setError,
|
||||||
|
setShowCustomInput,
|
||||||
|
setIsTransitioningToCustomInput,
|
||||||
|
seenMessageIds,
|
||||||
|
firstTextMessageSkipped,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle approval (Y key)
|
||||||
|
*/
|
||||||
|
const handleApprove = useCallback(() => {
|
||||||
|
if (!sendToExtension) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToExtension({ type: "askResponse", askResponse: "yesButtonClicked" })
|
||||||
|
setPendingAsk(null)
|
||||||
|
setLoading(true)
|
||||||
|
}, [sendToExtension, setPendingAsk, setLoading])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle rejection (N key)
|
||||||
|
*/
|
||||||
|
const handleReject = useCallback(() => {
|
||||||
|
if (!sendToExtension) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToExtension({ type: "askResponse", askResponse: "noButtonClicked" })
|
||||||
|
setPendingAsk(null)
|
||||||
|
setLoading(true)
|
||||||
|
}, [sendToExtension, setPendingAsk, setLoading])
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleSubmit,
|
||||||
|
handleApprove,
|
||||||
|
handleReject,
|
||||||
|
}
|
||||||
|
}
|
||||||
59
apps/cli/src/ui/hooks/useTerminalSize.ts
Normal file
59
apps/cli/src/ui/hooks/useTerminalSize.ts
Normal 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
|
||||||
|
}
|
||||||
196
apps/cli/src/ui/hooks/useToast.ts
Normal file
196
apps/cli/src/ui/hooks/useToast.ts
Normal 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
23
apps/cli/src/ui/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Main App
|
||||||
|
export { type TUIAppProps, App } from "./App.js"
|
||||||
|
|
||||||
|
// Components
|
||||||
|
export { default as Header } from "./components/Header.js"
|
||||||
|
export { default as ChatHistoryItem } from "./components/ChatHistoryItem.js"
|
||||||
|
export { default as LoadingText } from "./components/LoadingText.js"
|
||||||
|
|
||||||
|
// Autocomplete
|
||||||
|
export * from "./components/autocomplete/index.js"
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useInputHistory } from "./hooks/useInputHistory.js"
|
||||||
|
export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useInputHistory.js"
|
||||||
|
|
||||||
|
// Store
|
||||||
|
export { useCLIStore } from "./store.js"
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
export * as theme from "./theme.js"
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from "./types.js"
|
||||||
295
apps/cli/src/ui/store.ts
Normal file
295
apps/cli/src/ui/store.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { create } from "zustand"
|
||||||
|
|
||||||
|
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
|
||||||
|
|
||||||
|
import type { TUIMessage, PendingAsk, TaskHistoryItem } from "./types.js"
|
||||||
|
import type { FileResult, SlashCommandResult, ModeResult } from "./components/autocomplete/index.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow array equality check - compares array length and element references.
|
||||||
|
* Used to prevent unnecessary state updates when array content hasn't changed.
|
||||||
|
*/
|
||||||
|
function shallowArrayEqual<T>(a: T[], b: T[]): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming message debounce configuration.
|
||||||
|
* Batches rapid partial message updates to reduce re-renders during streaming.
|
||||||
|
* Higher values = fewer renders but text appears more "chunky"
|
||||||
|
* Lower values = smoother text but more renders
|
||||||
|
*/
|
||||||
|
const STREAMING_DEBOUNCE_MS = 150 // 150ms debounce for aggressive batching
|
||||||
|
|
||||||
|
// Pending streaming updates - batched and flushed after debounce interval
|
||||||
|
interface PendingStreamUpdate {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
partial: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingStreamUpdates: Map<string, PendingStreamUpdate> = new Map()
|
||||||
|
let streamingDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RouterModels type for context window lookup.
|
||||||
|
* Simplified version - we only need contextWindow from ModelInfo.
|
||||||
|
*/
|
||||||
|
export type RouterModels = Record<string, Record<string, { contextWindow?: number }>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI application state.
|
||||||
|
*
|
||||||
|
* Note: Autocomplete picker UI state (isOpen, selectedIndex) is now managed
|
||||||
|
* by the useAutocompletePicker hook. The store only holds data that needs
|
||||||
|
* to be shared between components or persisted (like search results from API).
|
||||||
|
*/
|
||||||
|
interface CLIState {
|
||||||
|
// Message history
|
||||||
|
messages: TUIMessage[]
|
||||||
|
pendingAsk: PendingAsk | null
|
||||||
|
|
||||||
|
// Task state
|
||||||
|
isLoading: boolean
|
||||||
|
isComplete: boolean
|
||||||
|
hasStartedTask: boolean
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
// Task resumption flag - true when resuming a task from history
|
||||||
|
// Used to modify message processing behavior (e.g., don't skip first text message)
|
||||||
|
isResumingTask: boolean
|
||||||
|
|
||||||
|
// Autocomplete data (from API/extension)
|
||||||
|
fileSearchResults: FileResult[]
|
||||||
|
allSlashCommands: SlashCommandResult[]
|
||||||
|
availableModes: ModeResult[]
|
||||||
|
|
||||||
|
// Task history (for resuming previous tasks)
|
||||||
|
taskHistory: TaskHistoryItem[]
|
||||||
|
|
||||||
|
// Current task ID (for detecting same-task reselection)
|
||||||
|
currentTaskId: string | null
|
||||||
|
|
||||||
|
// Current mode (updated reactively when mode changes)
|
||||||
|
currentMode: string | null
|
||||||
|
|
||||||
|
// Token usage metrics (from getApiMetrics)
|
||||||
|
tokenUsage: TokenUsage | null
|
||||||
|
|
||||||
|
// Model info for context window lookup
|
||||||
|
routerModels: RouterModels | null
|
||||||
|
apiConfiguration: ProviderSettings | null
|
||||||
|
|
||||||
|
// Todo list tracking
|
||||||
|
currentTodos: TodoItem[]
|
||||||
|
previousTodos: TodoItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CLIActions {
|
||||||
|
// Message actions
|
||||||
|
addMessage: (msg: TUIMessage) => void
|
||||||
|
updateMessage: (id: string, content: string, partial?: boolean) => void
|
||||||
|
|
||||||
|
// Task actions
|
||||||
|
setPendingAsk: (ask: PendingAsk | null) => void
|
||||||
|
setLoading: (loading: boolean) => void
|
||||||
|
setComplete: (complete: boolean) => void
|
||||||
|
setHasStartedTask: (started: boolean) => void
|
||||||
|
setError: (error: string | null) => void
|
||||||
|
reset: () => void
|
||||||
|
/** Reset for task switching - preserves global state (taskHistory, modes, commands) */
|
||||||
|
resetForTaskSwitch: () => void
|
||||||
|
/** Set the isResumingTask flag - used when resuming a task from history */
|
||||||
|
setIsResumingTask: (isResuming: boolean) => void
|
||||||
|
|
||||||
|
// Autocomplete data actions
|
||||||
|
setFileSearchResults: (results: FileResult[]) => void
|
||||||
|
setAllSlashCommands: (commands: SlashCommandResult[]) => void
|
||||||
|
setAvailableModes: (modes: ModeResult[]) => void
|
||||||
|
|
||||||
|
// Task history action
|
||||||
|
setTaskHistory: (history: TaskHistoryItem[]) => void
|
||||||
|
|
||||||
|
// Current task ID action
|
||||||
|
setCurrentTaskId: (taskId: string | null) => void
|
||||||
|
|
||||||
|
// Current mode action
|
||||||
|
setCurrentMode: (mode: string | null) => void
|
||||||
|
|
||||||
|
// Metrics actions
|
||||||
|
setTokenUsage: (usage: TokenUsage | null) => void
|
||||||
|
setRouterModels: (models: RouterModels | null) => void
|
||||||
|
setApiConfiguration: (config: ProviderSettings | null) => void
|
||||||
|
|
||||||
|
// Todo actions
|
||||||
|
setTodos: (todos: TodoItem[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CLIState = {
|
||||||
|
messages: [],
|
||||||
|
pendingAsk: null,
|
||||||
|
isLoading: false,
|
||||||
|
isComplete: false,
|
||||||
|
hasStartedTask: false,
|
||||||
|
error: null,
|
||||||
|
isResumingTask: false,
|
||||||
|
fileSearchResults: [],
|
||||||
|
allSlashCommands: [],
|
||||||
|
availableModes: [],
|
||||||
|
taskHistory: [],
|
||||||
|
currentTaskId: null,
|
||||||
|
currentMode: null,
|
||||||
|
tokenUsage: null,
|
||||||
|
routerModels: null,
|
||||||
|
apiConfiguration: null,
|
||||||
|
currentTodos: [],
|
||||||
|
previousTodos: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCLIStore = create<CLIState & CLIActions>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
addMessage: (msg) => {
|
||||||
|
const state = get()
|
||||||
|
// Check if message already exists (by ID).
|
||||||
|
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
|
||||||
|
|
||||||
|
// For NEW messages (not updates) - always apply immediately
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
set({ messages: [...state.messages, msg] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For UPDATES to existing messages:
|
||||||
|
// If partial (streaming) and message exists, debounce the update
|
||||||
|
if (msg.partial) {
|
||||||
|
// Queue the update
|
||||||
|
pendingStreamUpdates.set(msg.id, {
|
||||||
|
id: msg.id,
|
||||||
|
content: msg.content,
|
||||||
|
partial: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schedule flush if not already scheduled
|
||||||
|
if (!streamingDebounceTimer) {
|
||||||
|
streamingDebounceTimer = setTimeout(() => {
|
||||||
|
// Flush all pending updates as a single batch
|
||||||
|
const currentState = get()
|
||||||
|
const updates = Array.from(pendingStreamUpdates.values())
|
||||||
|
pendingStreamUpdates.clear()
|
||||||
|
streamingDebounceTimer = null
|
||||||
|
|
||||||
|
if (updates.length === 0) return
|
||||||
|
|
||||||
|
// Apply all pending updates in one state change
|
||||||
|
const newMessages = [...currentState.messages]
|
||||||
|
let hasChanges = false
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const idx = newMessages.findIndex((m) => m.id === update.id)
|
||||||
|
if (idx !== -1 && newMessages[idx]) {
|
||||||
|
newMessages[idx] = {
|
||||||
|
...newMessages[idx],
|
||||||
|
content: update.content,
|
||||||
|
partial: update.partial,
|
||||||
|
}
|
||||||
|
hasChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
set({ messages: newMessages })
|
||||||
|
}
|
||||||
|
}, STREAMING_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-partial update (final message) - apply immediately and clear any pending
|
||||||
|
// This ensures the final complete message is always shown
|
||||||
|
pendingStreamUpdates.delete(msg.id)
|
||||||
|
|
||||||
|
const updated = [...state.messages]
|
||||||
|
updated[existingIndex] = msg
|
||||||
|
set({ messages: updated })
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessage: (id, content, partial) =>
|
||||||
|
set((state) => {
|
||||||
|
const index = state.messages.findIndex((m) => m.id === id)
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = state.messages[index]
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = [...state.messages]
|
||||||
|
|
||||||
|
updated[index] = {
|
||||||
|
...existing,
|
||||||
|
content,
|
||||||
|
partial: partial !== undefined ? partial : existing.partial,
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: updated }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPendingAsk: (ask) => set({ pendingAsk: ask }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
setComplete: (complete) => set({ isComplete: complete }),
|
||||||
|
setHasStartedTask: (started) => set({ hasStartedTask: started }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
resetForTaskSwitch: () =>
|
||||||
|
set((state) => ({
|
||||||
|
// Clear task-specific state
|
||||||
|
messages: [],
|
||||||
|
pendingAsk: null,
|
||||||
|
isLoading: false,
|
||||||
|
isComplete: false,
|
||||||
|
hasStartedTask: false,
|
||||||
|
error: null,
|
||||||
|
isResumingTask: false,
|
||||||
|
tokenUsage: null,
|
||||||
|
currentTodos: [],
|
||||||
|
previousTodos: [],
|
||||||
|
// currentTaskId is preserved - will be updated to new task ID by caller
|
||||||
|
currentTaskId: state.currentTaskId,
|
||||||
|
// PRESERVE global state - don't clear these
|
||||||
|
taskHistory: state.taskHistory,
|
||||||
|
availableModes: state.availableModes,
|
||||||
|
allSlashCommands: state.allSlashCommands,
|
||||||
|
fileSearchResults: state.fileSearchResults,
|
||||||
|
currentMode: state.currentMode,
|
||||||
|
routerModels: state.routerModels,
|
||||||
|
apiConfiguration: state.apiConfiguration,
|
||||||
|
})),
|
||||||
|
setIsResumingTask: (isResuming) => set({ isResumingTask: isResuming }),
|
||||||
|
// Use shallow equality to prevent unnecessary re-renders when array content is the same
|
||||||
|
setFileSearchResults: (results) =>
|
||||||
|
set((state) => (shallowArrayEqual(state.fileSearchResults, results) ? state : { fileSearchResults: results })),
|
||||||
|
setAllSlashCommands: (commands) =>
|
||||||
|
set((state) => (shallowArrayEqual(state.allSlashCommands, commands) ? state : { allSlashCommands: commands })),
|
||||||
|
setAvailableModes: (modes) =>
|
||||||
|
set((state) => (shallowArrayEqual(state.availableModes, modes) ? state : { availableModes: modes })),
|
||||||
|
setTaskHistory: (history) =>
|
||||||
|
set((state) => (shallowArrayEqual(state.taskHistory, history) ? state : { taskHistory: history })),
|
||||||
|
setCurrentTaskId: (taskId) => set({ currentTaskId: taskId }),
|
||||||
|
setCurrentMode: (mode) => set({ currentMode: mode }),
|
||||||
|
setTokenUsage: (usage) => set({ tokenUsage: usage }),
|
||||||
|
setRouterModels: (models) => set({ routerModels: models }),
|
||||||
|
setApiConfiguration: (config) => set({ apiConfiguration: config }),
|
||||||
|
setTodos: (todos) => set((state) => ({ previousTodos: state.currentTodos, currentTodos: todos })),
|
||||||
|
}))
|
||||||
87
apps/cli/src/ui/stores/uiStateStore.ts
Normal file
87
apps/cli/src/ui/stores/uiStateStore.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { create } from "zustand"
|
||||||
|
import type { AutocompletePickerState } from "../components/autocomplete/types.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-specific state that doesn't need to persist across task switches.
|
||||||
|
* This separates UI state from task/message state in the main CLI store.
|
||||||
|
*/
|
||||||
|
interface UIState {
|
||||||
|
// Exit handling state
|
||||||
|
showExitHint: boolean
|
||||||
|
pendingExit: boolean
|
||||||
|
|
||||||
|
// Countdown timer for auto-accepting followup questions
|
||||||
|
countdownSeconds: number | null
|
||||||
|
|
||||||
|
// Custom input mode for followup questions
|
||||||
|
showCustomInput: boolean
|
||||||
|
isTransitioningToCustomInput: boolean
|
||||||
|
|
||||||
|
// Focus management for scroll area vs input
|
||||||
|
manualFocus: "scroll" | "input" | null
|
||||||
|
|
||||||
|
// TODO viewer overlay
|
||||||
|
showTodoViewer: boolean
|
||||||
|
|
||||||
|
// Autocomplete picker state
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
pickerState: AutocompletePickerState<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UIActions {
|
||||||
|
// Exit handling actions
|
||||||
|
setShowExitHint: (show: boolean) => void
|
||||||
|
setPendingExit: (pending: boolean) => void
|
||||||
|
|
||||||
|
// Countdown timer actions
|
||||||
|
setCountdownSeconds: (seconds: number | null) => void
|
||||||
|
|
||||||
|
// Custom input mode actions
|
||||||
|
setShowCustomInput: (show: boolean) => void
|
||||||
|
setIsTransitioningToCustomInput: (transitioning: boolean) => void
|
||||||
|
|
||||||
|
// Focus management actions
|
||||||
|
setManualFocus: (focus: "scroll" | "input" | null) => void
|
||||||
|
|
||||||
|
// TODO viewer actions
|
||||||
|
setShowTodoViewer: (show: boolean) => void
|
||||||
|
|
||||||
|
// Picker state actions
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
setPickerState: (state: AutocompletePickerState<any>) => void
|
||||||
|
|
||||||
|
// Reset all UI state to defaults
|
||||||
|
resetUIState: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: UIState = {
|
||||||
|
showExitHint: false,
|
||||||
|
pendingExit: false,
|
||||||
|
countdownSeconds: null,
|
||||||
|
showCustomInput: false,
|
||||||
|
isTransitioningToCustomInput: false,
|
||||||
|
manualFocus: null,
|
||||||
|
showTodoViewer: false,
|
||||||
|
pickerState: {
|
||||||
|
activeTrigger: null,
|
||||||
|
results: [],
|
||||||
|
selectedIndex: 0,
|
||||||
|
isOpen: false,
|
||||||
|
isLoading: false,
|
||||||
|
triggerInfo: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStateStore = create<UIState & UIActions>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setShowExitHint: (show) => set({ showExitHint: show }),
|
||||||
|
setPendingExit: (pending) => set({ pendingExit: pending }),
|
||||||
|
setCountdownSeconds: (seconds) => set({ countdownSeconds: seconds }),
|
||||||
|
setShowCustomInput: (show) => set({ showCustomInput: show }),
|
||||||
|
setIsTransitioningToCustomInput: (transitioning) => set({ isTransitioningToCustomInput: transitioning }),
|
||||||
|
setManualFocus: (focus) => set({ manualFocus: focus }),
|
||||||
|
setShowTodoViewer: (show) => set({ showTodoViewer: show }),
|
||||||
|
setPickerState: (state) => set({ pickerState: state }),
|
||||||
|
resetUIState: () => set(initialState),
|
||||||
|
}))
|
||||||
79
apps/cli/src/ui/theme.ts
Normal file
79
apps/cli/src/ui/theme.ts
Normal 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
140
apps/cli/src/ui/types.ts
Normal 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
|
||||||
|
}
|
||||||
9
apps/cli/src/ui/utils/index.ts
Normal file
9
apps/cli/src/ui/utils/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export {
|
||||||
|
extractToolData,
|
||||||
|
formatToolOutput,
|
||||||
|
formatToolAskMessage,
|
||||||
|
parseTodosFromToolInfo,
|
||||||
|
parseMarkdownChecklist,
|
||||||
|
} from "./toolDataUtils.js"
|
||||||
|
|
||||||
|
export { getView } from "./viewUtils.js"
|
||||||
345
apps/cli/src/ui/utils/toolDataUtils.ts
Normal file
345
apps/cli/src/ui/utils/toolDataUtils.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import type { TodoItem } from "@roo-code/types"
|
||||||
|
import type { ToolData } from "../types.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract structured ToolData from parsed tool JSON
|
||||||
|
* This provides rich data for tool-specific renderers
|
||||||
|
*/
|
||||||
|
export function extractToolData(toolInfo: Record<string, unknown>): ToolData {
|
||||||
|
const toolName = (toolInfo.tool as string) || "unknown"
|
||||||
|
|
||||||
|
// Base tool data with common fields
|
||||||
|
const toolData: ToolData = {
|
||||||
|
tool: toolName,
|
||||||
|
path: toolInfo.path as string | undefined,
|
||||||
|
isOutsideWorkspace: toolInfo.isOutsideWorkspace as boolean | undefined,
|
||||||
|
isProtected: toolInfo.isProtected as boolean | undefined,
|
||||||
|
content: toolInfo.content as string | undefined,
|
||||||
|
reason: toolInfo.reason as string | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract diff-related fields
|
||||||
|
if (toolInfo.diff !== undefined) {
|
||||||
|
toolData.diff = toolInfo.diff as string
|
||||||
|
}
|
||||||
|
if (toolInfo.diffStats !== undefined) {
|
||||||
|
const stats = toolInfo.diffStats as { added?: number; removed?: number }
|
||||||
|
if (typeof stats.added === "number" && typeof stats.removed === "number") {
|
||||||
|
toolData.diffStats = { added: stats.added, removed: stats.removed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract search-related fields
|
||||||
|
if (toolInfo.regex !== undefined) {
|
||||||
|
toolData.regex = toolInfo.regex as string
|
||||||
|
}
|
||||||
|
if (toolInfo.filePattern !== undefined) {
|
||||||
|
toolData.filePattern = toolInfo.filePattern as string
|
||||||
|
}
|
||||||
|
if (toolInfo.query !== undefined) {
|
||||||
|
toolData.query = toolInfo.query as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract mode-related fields
|
||||||
|
if (toolInfo.mode !== undefined) {
|
||||||
|
toolData.mode = toolInfo.mode as string
|
||||||
|
}
|
||||||
|
if (toolInfo.mode_slug !== undefined) {
|
||||||
|
toolData.mode = toolInfo.mode_slug as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract command-related fields
|
||||||
|
if (toolInfo.command !== undefined) {
|
||||||
|
toolData.command = toolInfo.command as string
|
||||||
|
}
|
||||||
|
if (toolInfo.output !== undefined) {
|
||||||
|
toolData.output = toolInfo.output as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract browser-related fields
|
||||||
|
if (toolInfo.action !== undefined) {
|
||||||
|
toolData.action = toolInfo.action as string
|
||||||
|
}
|
||||||
|
if (toolInfo.url !== undefined) {
|
||||||
|
toolData.url = toolInfo.url as string
|
||||||
|
}
|
||||||
|
if (toolInfo.coordinate !== undefined) {
|
||||||
|
toolData.coordinate = toolInfo.coordinate as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract batch file operations
|
||||||
|
if (Array.isArray(toolInfo.files)) {
|
||||||
|
toolData.batchFiles = (toolInfo.files as Array<Record<string, unknown>>).map((f) => ({
|
||||||
|
path: (f.path as string) || "",
|
||||||
|
lineSnippet: f.lineSnippet as string | undefined,
|
||||||
|
isOutsideWorkspace: f.isOutsideWorkspace as boolean | undefined,
|
||||||
|
key: f.key as string | undefined,
|
||||||
|
content: f.content as string | undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract batch diff operations
|
||||||
|
if (Array.isArray(toolInfo.batchDiffs)) {
|
||||||
|
toolData.batchDiffs = (toolInfo.batchDiffs as Array<Record<string, unknown>>).map((d) => ({
|
||||||
|
path: (d.path as string) || "",
|
||||||
|
changeCount: d.changeCount as number | undefined,
|
||||||
|
key: d.key as string | undefined,
|
||||||
|
content: d.content as string | undefined,
|
||||||
|
diffStats: d.diffStats as { added: number; removed: number } | undefined,
|
||||||
|
diffs: d.diffs as Array<{ content: string; startLine?: number }> | undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract question/completion fields
|
||||||
|
if (toolInfo.question !== undefined) {
|
||||||
|
toolData.question = toolInfo.question as string
|
||||||
|
}
|
||||||
|
if (toolInfo.result !== undefined) {
|
||||||
|
toolData.result = toolInfo.result as string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract additional display hints
|
||||||
|
if (toolInfo.lineNumber !== undefined) {
|
||||||
|
toolData.lineNumber = toolInfo.lineNumber as number
|
||||||
|
}
|
||||||
|
if (toolInfo.additionalFileCount !== undefined) {
|
||||||
|
toolData.additionalFileCount = toolInfo.additionalFileCount as number
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolData
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tool output for display (used in the message body, header shows tool name separately)
|
||||||
|
*/
|
||||||
|
export function formatToolOutput(toolInfo: Record<string, unknown>): string {
|
||||||
|
const toolName = (toolInfo.tool as string) || "unknown"
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "switchMode": {
|
||||||
|
const mode = (toolInfo.mode as string) || "unknown"
|
||||||
|
const reason = toolInfo.reason as string
|
||||||
|
return `→ ${mode} mode${reason ? `\n ${reason}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "switch_mode": {
|
||||||
|
const mode = (toolInfo.mode_slug as string) || (toolInfo.mode as string) || "unknown"
|
||||||
|
const reason = toolInfo.reason as string
|
||||||
|
return `→ ${mode} mode${reason ? `\n ${reason}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "execute_command": {
|
||||||
|
const command = toolInfo.command as string
|
||||||
|
return `$ ${command || "(no command)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "read_file": {
|
||||||
|
const files = toolInfo.files as Array<{ path: string }> | undefined
|
||||||
|
const path = toolInfo.path as string
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
return files.map((f) => `📄 ${f.path}`).join("\n")
|
||||||
|
}
|
||||||
|
return `📄 ${path || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "write_to_file": {
|
||||||
|
const writePath = toolInfo.path as string
|
||||||
|
return `📝 ${writePath || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "apply_diff": {
|
||||||
|
const diffPath = toolInfo.path as string
|
||||||
|
return `✏️ ${diffPath || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "search_files": {
|
||||||
|
const searchPath = toolInfo.path as string
|
||||||
|
const regex = toolInfo.regex as string
|
||||||
|
return `🔍 "${regex}" in ${searchPath || "."}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list_files": {
|
||||||
|
const listPath = toolInfo.path as string
|
||||||
|
const recursive = toolInfo.recursive as boolean
|
||||||
|
return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "browser_action": {
|
||||||
|
const action = toolInfo.action as string
|
||||||
|
const url = toolInfo.url as string
|
||||||
|
return `🌐 ${action || "action"}${url ? `: ${url}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "attempt_completion": {
|
||||||
|
const result = toolInfo.result as string
|
||||||
|
if (result) {
|
||||||
|
const truncated = result.length > 100 ? result.substring(0, 100) + "..." : result
|
||||||
|
return `✅ ${truncated}`
|
||||||
|
}
|
||||||
|
return "✅ Task completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ask_followup_question": {
|
||||||
|
const question = toolInfo.question as string
|
||||||
|
return `❓ ${question || "(no question)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "new_task": {
|
||||||
|
const taskMode = toolInfo.mode as string
|
||||||
|
return `📋 Creating subtask${taskMode ? ` in ${taskMode} mode` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "update_todo_list":
|
||||||
|
case "updateTodoList": {
|
||||||
|
// Special marker - actual rendering is handled by TodoChangeDisplay component
|
||||||
|
return "☑ TODO list updated"
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const params = Object.entries(toolInfo)
|
||||||
|
.filter(([key]) => key !== "tool")
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const displayValue = typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
const truncated = displayValue.length > 100 ? displayValue.substring(0, 100) + "..." : displayValue
|
||||||
|
return `${key}: ${truncated}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
return params || "(no parameters)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tool ask message for user approval prompt
|
||||||
|
*/
|
||||||
|
export function formatToolAskMessage(toolInfo: Record<string, unknown>): string {
|
||||||
|
const toolName = (toolInfo.tool as string) || "unknown"
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "switchMode":
|
||||||
|
case "switch_mode": {
|
||||||
|
const mode = (toolInfo.mode as string) || (toolInfo.mode_slug as string) || "unknown"
|
||||||
|
const reason = toolInfo.reason as string
|
||||||
|
return `Switch to ${mode} mode?${reason ? `\nReason: ${reason}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "execute_command": {
|
||||||
|
const command = toolInfo.command as string
|
||||||
|
return `Run command?\n$ ${command || "(no command)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "read_file": {
|
||||||
|
const files = toolInfo.files as Array<{ path: string }> | undefined
|
||||||
|
const path = toolInfo.path as string
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
return `Read ${files.length} file(s)?\n${files.map((f) => ` ${f.path}`).join("\n")}`
|
||||||
|
}
|
||||||
|
return `Read file: ${path || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "write_to_file": {
|
||||||
|
const writePath = toolInfo.path as string
|
||||||
|
return `Write to file: ${writePath || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "apply_diff": {
|
||||||
|
const diffPath = toolInfo.path as string
|
||||||
|
return `Apply changes to: ${diffPath || "(no path)"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
case "browser_action": {
|
||||||
|
const action = toolInfo.action as string
|
||||||
|
const url = toolInfo.url as string
|
||||||
|
return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const params = Object.entries(toolInfo)
|
||||||
|
.filter(([key]) => key !== "tool")
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const displayValue = typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
const truncated = displayValue.length > 80 ? displayValue.substring(0, 80) + "..." : displayValue
|
||||||
|
return ` ${key}: ${truncated}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
return `${toolName}${params ? `\n${params}` : ""}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse TODO items from tool info
|
||||||
|
* Handles both array format and markdown checklist string format
|
||||||
|
*/
|
||||||
|
export function parseTodosFromToolInfo(toolInfo: Record<string, unknown>): TodoItem[] | null {
|
||||||
|
// Try to get todos directly as an array
|
||||||
|
const todosArray = toolInfo.todos as unknown[] | undefined
|
||||||
|
if (Array.isArray(todosArray)) {
|
||||||
|
return todosArray
|
||||||
|
.map((item, index) => {
|
||||||
|
if (typeof item === "object" && item !== null) {
|
||||||
|
const todo = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: (todo.id as string) || `todo-${index}`,
|
||||||
|
content: (todo.content as string) || "",
|
||||||
|
status: ((todo.status as string) || "pending") as TodoItem["status"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter((item): item is TodoItem => item !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse markdown checklist format from todos string
|
||||||
|
const todosString = toolInfo.todos as string | undefined
|
||||||
|
if (typeof todosString === "string") {
|
||||||
|
return parseMarkdownChecklist(todosString)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a markdown checklist string into TodoItem array
|
||||||
|
* Format:
|
||||||
|
* [ ] pending item
|
||||||
|
* [-] in progress item
|
||||||
|
* [x] completed item
|
||||||
|
*/
|
||||||
|
export function parseMarkdownChecklist(markdown: string): TodoItem[] {
|
||||||
|
const lines = markdown.split("\n")
|
||||||
|
const todos: TodoItem[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
|
||||||
|
if (!line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
if (!trimmedLine) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match markdown checkbox patterns
|
||||||
|
const checkboxMatch = trimmedLine.match(/^\[([x\-\s])\]\s*(.+)$/i)
|
||||||
|
|
||||||
|
if (checkboxMatch) {
|
||||||
|
const statusChar = checkboxMatch[1] ?? " "
|
||||||
|
const content = checkboxMatch[2] ?? ""
|
||||||
|
let status: TodoItem["status"] = "pending"
|
||||||
|
|
||||||
|
if (statusChar.toLowerCase() === "x") {
|
||||||
|
status = "completed"
|
||||||
|
} else if (statusChar === "-") {
|
||||||
|
status = "in_progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
todos.push({ id: `todo-${i}`, content: content.trim(), status })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return todos
|
||||||
|
}
|
||||||
52
apps/cli/src/ui/utils/viewUtils.ts
Normal file
52
apps/cli/src/ui/utils/viewUtils.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { TUIMessage, PendingAsk, View } from "../types.js"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the current view state based on messages and pending asks
|
||||||
|
*/
|
||||||
|
export function getView(messages: TUIMessage[], pendingAsk: PendingAsk | null, isLoading: boolean): View {
|
||||||
|
// If there's a pending ask requiring text input, show input
|
||||||
|
if (pendingAsk?.type === "followup") {
|
||||||
|
return "UserInput"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's any pending ask (approval), don't show thinking
|
||||||
|
if (pendingAsk) {
|
||||||
|
return "UserInput"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial state or empty - awaiting user input
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return "UserInput"
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = messages.at(-1)
|
||||||
|
if (!lastMessage) {
|
||||||
|
return "UserInput"
|
||||||
|
}
|
||||||
|
|
||||||
|
// User just sent a message, waiting for response
|
||||||
|
if (lastMessage.role === "user") {
|
||||||
|
return "AgentResponse"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant replied
|
||||||
|
if (lastMessage.role === "assistant") {
|
||||||
|
if (lastMessage.hasPendingToolCalls) {
|
||||||
|
return "ToolUse"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If loading, still waiting for more
|
||||||
|
if (isLoading) {
|
||||||
|
return "AgentResponse"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "UserInput"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool result received, waiting for next assistant response
|
||||||
|
if (lastMessage.role === "tool") {
|
||||||
|
return "AgentResponse"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Default"
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
/**
|
|
||||||
* Unit tests for CLI utility functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
|
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
// Mock fs module
|
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
|
||||||
|
|
||||||
vi.mock("fs")
|
vi.mock("fs")
|
||||||
|
|
||||||
describe("getEnvVarName", () => {
|
describe("getEnvVarName", () => {
|
||||||
|
|
@ -80,8 +76,17 @@ describe("getApiKeyFromEnv", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getDefaultExtensionPath", () => {
|
describe("getDefaultExtensionPath", () => {
|
||||||
|
const originalEnv = process.env
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks()
|
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", () => {
|
it("should return monorepo path when extension.js exists there", () => {
|
||||||
102
apps/cli/src/utils/__tests__/globalCommands.test.ts
Normal file
102
apps/cli/src/utils/__tests__/globalCommands.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
128
apps/cli/src/utils/__tests__/globalInputSequences.test.ts
Normal file
128
apps/cli/src/utils/__tests__/globalInputSequences.test.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
232
apps/cli/src/utils/__tests__/historyStorage.test.ts
Normal file
232
apps/cli/src/utils/__tests__/historyStorage.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
67
apps/cli/src/utils/getContextWindow.ts
Normal file
67
apps/cli/src/utils/getContextWindow.ts
Normal 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 }
|
||||||
62
apps/cli/src/utils/globalCommands.ts
Normal file
62
apps/cli/src/utils/globalCommands.ts
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
122
apps/cli/src/utils/globalInputSequences.ts
Normal file
122
apps/cli/src/utils/globalInputSequences.ts
Normal 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
|
||||||
|
}
|
||||||
131
apps/cli/src/utils/historyStorage.ts
Normal file
131
apps/cli/src/utils/historyStorage.ts
Normal 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)
|
||||||
|
}
|
||||||
57
apps/cli/src/utils/pathUtils.test.ts
Normal file
57
apps/cli/src/utils/pathUtils.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
35
apps/cli/src/utils/pathUtils.ts
Normal file
35
apps/cli/src/utils/pathUtils.ts
Normal 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
|
||||||
|
}
|
||||||
66
apps/cli/src/utils/toolInspectorLogger.ts
Normal file
66
apps/cli/src/utils/toolInspectorLogger.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
"extends": "@roo-code/config-typescript/base.json",
|
"extends": "@roo-code/config-typescript/base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["vitest/globals"],
|
"types": ["vitest/globals"],
|
||||||
"outDir": "dist"
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "react"
|
||||||
},
|
},
|
||||||
"include": ["src", "*.config.ts"],
|
"include": ["src", "*.config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
||||||
js: "#!/usr/bin/env node",
|
js: "#!/usr/bin/env node",
|
||||||
},
|
},
|
||||||
// Bundle workspace packages that export TypeScript
|
// 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: [
|
external: [
|
||||||
// Keep native modules external
|
// Keep native modules external
|
||||||
"@anthropic-ai/sdk",
|
"@anthropic-ai/sdk",
|
||||||
|
|
@ -20,5 +20,12 @@ export default defineConfig({
|
||||||
"@anthropic-ai/vertex-sdk",
|
"@anthropic-ai/vertex-sdk",
|
||||||
// Keep @vscode/ripgrep external - we bundle the binary separately
|
// Keep @vscode/ripgrep external - we bundle the binary separately
|
||||||
"@vscode/ripgrep",
|
"@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"
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,6 @@ export default defineConfig({
|
||||||
environment: "node",
|
environment: "node",
|
||||||
watch: false,
|
watch: false,
|
||||||
testTimeout: 120_000, // 2m for integration tests.
|
testTimeout: 120_000, // 2m for integration tests.
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
"@vscode/test-electron": "^2.4.0",
|
"@vscode/test-electron": "^2.4.0",
|
||||||
"glob": "^11.1.0",
|
"glob": "^11.1.0",
|
||||||
"mocha": "^11.1.0",
|
"mocha": "^11.1.0",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1"
|
||||||
"typescript": "5.8.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"turbo": "^2.5.6",
|
"turbo": "^2.5.6",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "5.8.3"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx,json,css,md}": [
|
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||||
|
|
@ -63,7 +63,9 @@
|
||||||
"brace-expansion": "^2.0.2",
|
"brace-expansion": "^2.0.2",
|
||||||
"form-data": ">=4.0.4",
|
"form-data": ">=4.0.4",
|
||||||
"bluebird": ">=3.7.2",
|
"bluebird": ">=3.7.2",
|
||||||
"glob": ">=11.1.0"
|
"glob": ">=11.1.0",
|
||||||
|
"@types/react": "^18.3.23",
|
||||||
|
"@types/react-dom": "^18.3.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@
|
||||||
"description": "Platform agnostic core functionality for Roo Code.",
|
"description": "Platform agnostic core functionality for Roo Code.",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"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": {
|
"scripts": {
|
||||||
"lint": "eslint src --ext=ts --max-warnings=0",
|
"lint": "eslint src --ext=ts --max-warnings=0",
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue