mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Compare commits
21 commits
main
...
cli-v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530b67d2ba | ||
|
|
616472f807 | ||
|
|
303598f014 | ||
|
|
3d7117bb7b | ||
|
|
5ec80f94eb | ||
|
|
58793f4aea | ||
|
|
8072a90a7d | ||
|
|
e85362fb11 | ||
|
|
b6f571cadf | ||
|
|
c95706e345 | ||
|
|
246062c86f | ||
|
|
c9d52349d5 | ||
|
|
a590932727 | ||
|
|
262aaa52d1 | ||
|
|
0a8f1a13c4 | ||
|
|
b4fe095bf1 | ||
|
|
930755e44a | ||
|
|
ad6ce88583 | ||
|
|
c137cf44c8 | ||
|
|
53ad2e1d6a | ||
|
|
a81438de3e |
159 changed files with 12384 additions and 1413 deletions
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."
|
||||
|
|
@ -71,7 +71,13 @@ By default, the CLI prompts for approval before executing actions:
|
|||
```bash
|
||||
export OPENROUTER_API_KEY=sk-or-v1-...
|
||||
|
||||
roo "What is this project?" --workspace ~/Documents/my-project
|
||||
roo ~/Documents/my-project -P "What is this project?"
|
||||
```
|
||||
|
||||
You can also run without a prompt and enter it interactively in TUI mode:
|
||||
|
||||
```bash
|
||||
roo ~/Documents/my-project
|
||||
```
|
||||
|
||||
In interactive mode:
|
||||
|
|
@ -86,7 +92,7 @@ In interactive mode:
|
|||
For automation and scripts, use `-y` to auto-approve all actions:
|
||||
|
||||
```bash
|
||||
roo -y "Refactor the utils.ts file" --workspace ~/Documents/my-project
|
||||
roo ~/Documents/my-project -y -P "Refactor the utils.ts file"
|
||||
```
|
||||
|
||||
In non-interactive mode:
|
||||
|
|
@ -99,7 +105,8 @@ In non-interactive mode:
|
|||
|
||||
| Option | Description | Default |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
|
||||
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
|
||||
| `[workspace]` | Workspace path to operate in (positional argument) | Current directory |
|
||||
| `-P, --prompt <prompt>` | The prompt/task to execute (optional in TUI mode) | None |
|
||||
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
||||
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
|
||||
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ print_success() {
|
|||
echo ""
|
||||
echo " ${BOLD}Example:${NC}"
|
||||
echo " export OPENROUTER_API_KEY=sk-or-v1-..."
|
||||
echo " roo \"What is this project?\" --workspace ~/my-project"
|
||||
echo " roo ~/my-project -P \"What is this project?\""
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@roo-code/cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
@ -14,22 +14,30 @@
|
|||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inkjs/ui": "^2.0.0",
|
||||
"@roo-code/core": "workspace:^",
|
||||
"@roo-code/types": "workspace:^",
|
||||
"@roo-code/vscode-shim": "workspace:^",
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"commander": "^12.1.0"
|
||||
"commander": "^12.1.0",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"react": "^19.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
"@roo-code/config-typescript": "workspace:^",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.6",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ create_tarball() {
|
|||
info "Copying CLI files..."
|
||||
cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/"
|
||||
|
||||
# Create package.json for npm install (only runtime dependencies)
|
||||
# Create package.json for npm install (runtime dependencies that can't be bundled)
|
||||
info "Creating package.json..."
|
||||
node -e "
|
||||
const pkg = require('$CLI_DIR/package.json');
|
||||
|
|
@ -139,7 +139,11 @@ create_tarball() {
|
|||
version: pkg.version,
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
commander: pkg.dependencies.commander
|
||||
'@inkjs/ui': pkg.dependencies['@inkjs/ui'],
|
||||
'commander': pkg.dependencies.commander,
|
||||
'ink': pkg.dependencies.ink,
|
||||
'react': pkg.dependencies.react,
|
||||
'zustand': pkg.dependencies.zustand
|
||||
}
|
||||
};
|
||||
console.log(JSON.stringify(newPkg, null, 2));
|
||||
|
|
@ -197,6 +201,9 @@ WRAPPER_EOF
|
|||
# Create version file
|
||||
echo "$VERSION" > "$RELEASE_DIR/VERSION"
|
||||
|
||||
# Create empty .env file to suppress dotenvx warnings
|
||||
touch "$RELEASE_DIR/.env"
|
||||
|
||||
# Create tarball
|
||||
info "Creating tarball..."
|
||||
cd "$REPO_ROOT"
|
||||
|
|
|
|||
187
apps/cli/src/__tests__/EscapeKeyCancel.test.ts
Normal file
187
apps/cli/src/__tests__/EscapeKeyCancel.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Tests for Escape key cancel/pause functionality
|
||||
*
|
||||
* When the CLI is in a loading state (streaming LLM API calls),
|
||||
* pressing Escape should send a "cancelTask" message to the extension,
|
||||
* similar to the Cancel button in the webview-ui.
|
||||
*/
|
||||
|
||||
describe("Escape key cancel behavior", () => {
|
||||
describe("escape key detection logic", () => {
|
||||
/**
|
||||
* Simulates the escape key handling logic from App.tsx
|
||||
*
|
||||
* @param key - The key object from ink's useInput
|
||||
* @param isLoading - Whether the app is currently loading (streaming)
|
||||
* @param hasHostRef - Whether the extension host reference is available
|
||||
* @param isPickerOpen - Whether an autocomplete picker is currently open
|
||||
* @returns An object describing what action should be taken
|
||||
*/
|
||||
const handleEscapeKey = (
|
||||
key: { escape: boolean },
|
||||
isLoading: boolean,
|
||||
hasHostRef: boolean,
|
||||
isPickerOpen: boolean,
|
||||
): { shouldCancel: boolean; reason?: string } => {
|
||||
if (!key.escape) {
|
||||
return { shouldCancel: false, reason: "Not escape key" }
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
return { shouldCancel: false, reason: "Not in loading state" }
|
||||
}
|
||||
|
||||
if (!hasHostRef) {
|
||||
return { shouldCancel: false, reason: "No host reference" }
|
||||
}
|
||||
|
||||
if (isPickerOpen) {
|
||||
// Let picker handle escape first
|
||||
return { shouldCancel: false, reason: "Picker is open" }
|
||||
}
|
||||
|
||||
return { shouldCancel: true }
|
||||
}
|
||||
|
||||
it("should cancel task when escape is pressed during loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(true)
|
||||
})
|
||||
|
||||
it("should not cancel when not loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
false, // isLoading - not loading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not in loading state")
|
||||
})
|
||||
|
||||
it("should not cancel when host reference is not available", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
false, // hasHostRef - no host reference
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("No host reference")
|
||||
})
|
||||
|
||||
it("should not cancel when picker is open", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
true, // isPickerOpen - picker is open
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Picker is open")
|
||||
})
|
||||
|
||||
it("should not do anything for non-escape keys", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: false }, // Not escape key
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not escape key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel message format", () => {
|
||||
it("should create the correct message format for cancelTask", () => {
|
||||
// The message sent to extension should match webview-ui format
|
||||
const cancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cancelMessage).toEqual({ type: "cancelTask" })
|
||||
expect(cancelMessage.type).toBe("cancelTask")
|
||||
})
|
||||
|
||||
it("should match the webview-ui cancel message format", () => {
|
||||
// From webview-ui/src/components/chat/ChatView.tsx line 750:
|
||||
// vscode.postMessage({ type: "cancelTask" })
|
||||
const webviewCancelMessage = { type: "cancelTask" }
|
||||
const cliCancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cliCancelMessage).toEqual(webviewCancelMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loading state scenarios", () => {
|
||||
/**
|
||||
* The isLoading state in the CLI store represents:
|
||||
* - Active API request in progress
|
||||
* - Task is streaming responses
|
||||
* - Agent is "thinking" or processing
|
||||
*/
|
||||
|
||||
it("should identify loading state during agent response", () => {
|
||||
const view = "AgentResponse"
|
||||
const isLoading = true
|
||||
|
||||
// During agent response, cancel should be available
|
||||
expect(view).toBe("AgentResponse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify loading state during tool use", () => {
|
||||
const view = "ToolUse"
|
||||
const isLoading = true
|
||||
|
||||
// During tool use, cancel should be available
|
||||
expect(view).toBe("ToolUse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should not identify loading state during user input", () => {
|
||||
const view = "UserInput"
|
||||
const isLoading = false
|
||||
|
||||
// During user input, no need for cancel
|
||||
expect(view).toBe("UserInput")
|
||||
expect(isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel behavior expectations", () => {
|
||||
it("should pause the task (not terminate)", () => {
|
||||
// The cancelTask message pauses the task, allowing the user to:
|
||||
// 1. Review the current state
|
||||
// 2. Provide additional input
|
||||
// 3. Resume the task by typing something
|
||||
const cancelBehavior = {
|
||||
action: "pause",
|
||||
terminates: false,
|
||||
allowsResume: true,
|
||||
resumeMethod: "user provides input",
|
||||
}
|
||||
|
||||
expect(cancelBehavior.action).toBe("pause")
|
||||
expect(cancelBehavior.terminates).toBe(false)
|
||||
expect(cancelBehavior.allowsResume).toBe(true)
|
||||
})
|
||||
|
||||
it("should allow resuming by typing after cancel", () => {
|
||||
// After cancel, the user can resume by typing a message
|
||||
const postCancelState = {
|
||||
isLoading: false, // Loading stops
|
||||
canTypeMessage: true, // User can type
|
||||
messageResumesTask: true, // Typing resumes the task
|
||||
}
|
||||
|
||||
expect(postCancelState.isLoading).toBe(false)
|
||||
expect(postCancelState.canTypeMessage).toBe(true)
|
||||
expect(postCancelState.messageResumesTask).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
568
apps/cli/src/__tests__/MultilineTextInput.test.ts
Normal file
568
apps/cli/src/__tests__/MultilineTextInput.test.ts
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
/**
|
||||
* Tests for MultilineTextInput component
|
||||
*/
|
||||
|
||||
describe("MultilineTextInput", () => {
|
||||
describe("cursor position calculations", () => {
|
||||
// Test the getCursorPosition logic
|
||||
const 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 }
|
||||
}
|
||||
|
||||
// Test the getIndexFromPosition logic
|
||||
const 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
|
||||
}
|
||||
|
||||
it("should calculate cursor position for single line", () => {
|
||||
const value = "hello"
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 2)).toEqual({ line: 0, col: 2 })
|
||||
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 })
|
||||
})
|
||||
|
||||
it("should calculate cursor position for multiple lines", () => {
|
||||
const value = "hello\nworld"
|
||||
// "hello" is 5 chars, newline at index 5
|
||||
// "world" starts at index 6
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) // End of first line
|
||||
expect(getCursorPosition(value, 6)).toEqual({ line: 1, col: 0 }) // Start of second line
|
||||
expect(getCursorPosition(value, 8)).toEqual({ line: 1, col: 2 }) // Middle of second line
|
||||
expect(getCursorPosition(value, 11)).toEqual({ line: 1, col: 5 }) // End of second line
|
||||
})
|
||||
|
||||
it("should calculate cursor position for three lines", () => {
|
||||
const value = "foo\nbar\nbaz"
|
||||
// "foo" = 3 chars, newline at 3
|
||||
// "bar" starts at 4, ends at 6, newline at 7
|
||||
// "baz" starts at 8
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 3)).toEqual({ line: 0, col: 3 })
|
||||
expect(getCursorPosition(value, 4)).toEqual({ line: 1, col: 0 })
|
||||
expect(getCursorPosition(value, 7)).toEqual({ line: 1, col: 3 })
|
||||
expect(getCursorPosition(value, 8)).toEqual({ line: 2, col: 0 })
|
||||
expect(getCursorPosition(value, 11)).toEqual({ line: 2, col: 3 })
|
||||
})
|
||||
|
||||
it("should calculate index from position for single line", () => {
|
||||
const value = "hello"
|
||||
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
|
||||
expect(getIndexFromPosition(value, 0, 2)).toBe(2)
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
|
||||
})
|
||||
|
||||
it("should calculate index from position for multiple lines", () => {
|
||||
const value = "hello\nworld"
|
||||
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
|
||||
expect(getIndexFromPosition(value, 1, 0)).toBe(6)
|
||||
expect(getIndexFromPosition(value, 1, 2)).toBe(8)
|
||||
expect(getIndexFromPosition(value, 1, 5)).toBe(11)
|
||||
})
|
||||
|
||||
it("should clamp column to line length", () => {
|
||||
const value = "hi\nworld"
|
||||
// First line "hi" is only 2 chars, requesting col 5 should clamp to 2
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("line splitting", () => {
|
||||
it("should split empty string into single empty line", () => {
|
||||
const value = ""
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual([""])
|
||||
})
|
||||
|
||||
it("should split single line correctly", () => {
|
||||
const value = "hello world"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["hello world"])
|
||||
})
|
||||
|
||||
it("should split multiple lines correctly", () => {
|
||||
const value = "foo\nbar\nbaz"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", "baz"])
|
||||
})
|
||||
|
||||
it("should handle trailing newline", () => {
|
||||
const value = "foo\nbar\n"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", ""])
|
||||
})
|
||||
|
||||
it("should handle empty lines in middle", () => {
|
||||
const value = "foo\n\nbaz"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "", "baz"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("line normalization", () => {
|
||||
const normalizeLineEndings = (text: string): string => {
|
||||
if (text == null) return ""
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
it("should normalize CRLF to LF", () => {
|
||||
expect(normalizeLineEndings("hello\r\nworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should normalize CR to LF", () => {
|
||||
expect(normalizeLineEndings("hello\rworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should leave LF unchanged", () => {
|
||||
expect(normalizeLineEndings("hello\nworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should handle null/undefined", () => {
|
||||
expect(normalizeLineEndings(null as unknown as string)).toBe("")
|
||||
expect(normalizeLineEndings(undefined as unknown as string)).toBe("")
|
||||
})
|
||||
|
||||
it("should handle mixed line endings", () => {
|
||||
expect(normalizeLineEndings("a\r\nb\rc\nd")).toBe("a\nb\nc\nd")
|
||||
})
|
||||
})
|
||||
|
||||
describe("key binding behavior", () => {
|
||||
it("should detect Ctrl+Enter for newline insertion", () => {
|
||||
const isNewlineKey = (key: { return: boolean; ctrl: boolean }) => key.return && key.ctrl
|
||||
expect(isNewlineKey({ return: true, ctrl: true })).toBe(true)
|
||||
expect(isNewlineKey({ return: true, ctrl: false })).toBe(false)
|
||||
expect(isNewlineKey({ return: false, ctrl: true })).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect Enter for submit", () => {
|
||||
const isSubmitKey = (key: { return: boolean; ctrl: boolean }) => key.return && !key.ctrl
|
||||
expect(isSubmitKey({ return: true, ctrl: false })).toBe(true)
|
||||
expect(isSubmitKey({ return: true, ctrl: true })).toBe(false)
|
||||
expect(isSubmitKey({ return: false, ctrl: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("newline insertion", () => {
|
||||
it("should insert newline at cursor position", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 2
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("he\nllo")
|
||||
})
|
||||
|
||||
it("should insert newline at end", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 5
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("hello\n")
|
||||
})
|
||||
|
||||
it("should insert newline at start", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 0
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("\nhello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("backspace behavior", () => {
|
||||
it("should delete character before cursor", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 3
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("helo")
|
||||
})
|
||||
|
||||
it("should delete newline character (merge lines)", () => {
|
||||
const value = "hello\nworld"
|
||||
const cursorIndex = 6 // Start of "world" line
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("helloworld")
|
||||
})
|
||||
|
||||
it("should do nothing at start of input", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 0
|
||||
// In real implementation, we check if cursorIndex > 0
|
||||
if (cursorIndex > 0) {
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).not.toBe(value)
|
||||
}
|
||||
// At position 0, backspace does nothing
|
||||
expect(value).toBe("hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("arrow key navigation", () => {
|
||||
describe("up arrow", () => {
|
||||
it("should move to previous line preserving column", () => {
|
||||
const value = "hello\nworld"
|
||||
const getCursorPosition = (v: string, i: number) => {
|
||||
const lines = v.split("\n")
|
||||
let pos = 0
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li]!
|
||||
const lineEnd = pos + line.length
|
||||
if (i <= lineEnd) {
|
||||
return { line: li, col: i - pos }
|
||||
}
|
||||
pos = lineEnd + 1
|
||||
}
|
||||
return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
|
||||
}
|
||||
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "world"[2] (index 8)
|
||||
const cursorIndex = 8
|
||||
const { line, col } = getCursorPosition(value, cursorIndex)
|
||||
expect(line).toBe(1)
|
||||
expect(col).toBe(2)
|
||||
|
||||
// Move up: should go to line 0, same column
|
||||
const targetLine = 0
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(2) // "he|llo"
|
||||
})
|
||||
|
||||
it("should clamp column if target line is shorter", () => {
|
||||
const value = "hi\nworld"
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "world"[4] (index 7)
|
||||
// Moving up to "hi" which is only 2 chars, should clamp to col 2
|
||||
const targetLine = 0
|
||||
const col = 4
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(2) // End of "hi"
|
||||
})
|
||||
})
|
||||
|
||||
describe("down arrow", () => {
|
||||
it("should move to next line preserving column", () => {
|
||||
const value = "hello\nworld"
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "hello"[2] (index 2)
|
||||
const col = 2
|
||||
const targetLine = 1
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(8) // "wo|rld"
|
||||
})
|
||||
})
|
||||
|
||||
describe("left/right arrows", () => {
|
||||
it("should move left by 1", () => {
|
||||
const cursorIndex = 5
|
||||
const newIndex = Math.max(0, cursorIndex - 1)
|
||||
expect(newIndex).toBe(4)
|
||||
})
|
||||
|
||||
it("should not move left past 0", () => {
|
||||
const cursorIndex = 0
|
||||
const newIndex = Math.max(0, cursorIndex - 1)
|
||||
expect(newIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should move right by 1", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 2
|
||||
const newIndex = Math.min(value.length, cursorIndex + 1)
|
||||
expect(newIndex).toBe(3)
|
||||
})
|
||||
|
||||
it("should not move right past end", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 5
|
||||
const newIndex = Math.min(value.length, cursorIndex + 1)
|
||||
expect(newIndex).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("word-boundary line wrapping", () => {
|
||||
// Represents a visual row after wrapping a logical line
|
||||
interface VisualRow {
|
||||
text: string
|
||||
logicalLineIndex: number
|
||||
isFirstRowOfLine: boolean
|
||||
startCol: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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
|
||||
}
|
||||
|
||||
it("should not wrap text shorter than available width", () => {
|
||||
const rows = wrapLine("hello world", 0, 20)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("hello world")
|
||||
expect(rows[0]!.isFirstRowOfLine).toBe(true)
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
})
|
||||
|
||||
it("should wrap at word boundary when possible", () => {
|
||||
const rows = wrapLine("hello world foo", 0, 10)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.text).toBe("hello ")
|
||||
expect(rows[0]!.isFirstRowOfLine).toBe(true)
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
expect(rows[1]!.text).toBe("world foo")
|
||||
expect(rows[1]!.isFirstRowOfLine).toBe(false)
|
||||
expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars
|
||||
})
|
||||
|
||||
it("should break mid-word when no space found", () => {
|
||||
const rows = wrapLine("superlongwordwithoutspaces", 0, 10)
|
||||
expect(rows).toHaveLength(3)
|
||||
// Falls back to breaking at availableWidth when no space is found
|
||||
expect(rows[0]!.text).toBe("superlongw")
|
||||
expect(rows[1]!.text).toBe("ordwithout")
|
||||
expect(rows[2]!.text).toBe("spaces")
|
||||
})
|
||||
|
||||
it("should handle multiple word wraps", () => {
|
||||
const rows = wrapLine("one two three four five six", 0, 8)
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows[0]!.text).toBe("one two ")
|
||||
expect(rows[1]!.text).toBe("three ")
|
||||
expect(rows[2]!.text).toBe("four ")
|
||||
expect(rows[3]!.text).toBe("five six")
|
||||
})
|
||||
|
||||
it("should preserve logical line index", () => {
|
||||
const rows = wrapLine("hello world", 2, 6)
|
||||
expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const rows = wrapLine("", 0, 10)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("")
|
||||
})
|
||||
|
||||
it("should handle string that exactly matches width", () => {
|
||||
const rows = wrapLine("hello", 0, 5)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("hello")
|
||||
})
|
||||
|
||||
it("should track correct startCol for wrapped rows", () => {
|
||||
const rows = wrapLine("aa bb cc dd", 0, 5)
|
||||
// "aa bb cc dd" = 11 chars, width = 5
|
||||
// "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10)
|
||||
// Search backwards from index 4:
|
||||
// index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3
|
||||
// Row 0: "aa " (3 chars), startCol=0
|
||||
// Remaining: "bb cc dd" (8 chars), startCol=3
|
||||
// Search backwards from index 4:
|
||||
// "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)...
|
||||
// index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3
|
||||
// Row 1: "bb " (3 chars), startCol=3
|
||||
// Remaining: "cc dd" (5 chars), startCol=6
|
||||
// 5 <= 5, fits in one row
|
||||
// Row 2: "cc dd", startCol=6
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0]!.text).toBe("aa ")
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
expect(rows[1]!.text).toBe("bb ")
|
||||
expect(rows[1]!.startCol).toBe(3)
|
||||
expect(rows[2]!.text).toBe("cc dd")
|
||||
expect(rows[2]!.startCol).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-line history integration", () => {
|
||||
it("should store multi-line entries with newlines", () => {
|
||||
const entry = "foo\nbar\nbaz"
|
||||
expect(entry.includes("\n")).toBe(true)
|
||||
expect(entry.split("\n").length).toBe(3)
|
||||
})
|
||||
|
||||
it("should restore multi-line entries correctly", () => {
|
||||
const storedEntry = "foo\nbar\nbaz"
|
||||
const lines = storedEntry.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", "baz"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("cursor overflow prevention", () => {
|
||||
/**
|
||||
* Tests the logic that prevents visual shift when cursor is at the end
|
||||
* of a max-width row. Adding a cursor space character would overflow
|
||||
* the terminal width, causing text to shift left.
|
||||
*/
|
||||
|
||||
it("should detect when cursor space would overflow", () => {
|
||||
// Simulates the overflow detection logic from renderVisualRow
|
||||
const checkWouldOverflow = (
|
||||
columns: number | undefined,
|
||||
cursorAtEnd: boolean,
|
||||
prefixLen: number,
|
||||
textLen: number,
|
||||
): boolean => {
|
||||
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
|
||||
}
|
||||
|
||||
// Terminal width 80, prefix "> " (2 chars), text 78 chars = exactly full
|
||||
// Adding cursor space would make it 81 chars -> overflow
|
||||
expect(checkWouldOverflow(80, true, 2, 78)).toBe(true)
|
||||
|
||||
// Same scenario but cursor not at end -> no overflow issue
|
||||
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
|
||||
|
||||
// Text shorter than max width -> no overflow
|
||||
expect(checkWouldOverflow(80, true, 2, 50)).toBe(false)
|
||||
|
||||
// No columns specified -> no overflow detection
|
||||
expect(checkWouldOverflow(undefined, true, 2, 78)).toBe(false)
|
||||
|
||||
// Continuation line with shorter indent
|
||||
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
|
||||
|
||||
// Exactly at boundary (prefixLen + textLen + 1 === columns)
|
||||
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
|
||||
|
||||
// One character over would overflow
|
||||
expect(checkWouldOverflow(80, true, 3, 77)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not overflow when cursor is in the middle of text", () => {
|
||||
const checkWouldOverflow = (
|
||||
columns: number | undefined,
|
||||
cursorAtEnd: boolean,
|
||||
prefixLen: number,
|
||||
textLen: number,
|
||||
): boolean => {
|
||||
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
|
||||
}
|
||||
|
||||
// Cursor in middle of max-width row - no extra space added, no overflow
|
||||
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
|
||||
expect(checkWouldOverflow(80, false, 2, 100)).toBe(false)
|
||||
})
|
||||
|
||||
it("should correctly identify cursor at end of row", () => {
|
||||
// Simulates the cursorAtEnd check
|
||||
const isCursorAtEnd = (cursorColInRow: number, textLen: number): boolean => {
|
||||
return cursorColInRow >= textLen
|
||||
}
|
||||
|
||||
expect(isCursorAtEnd(5, 5)).toBe(true) // cursor at position 5, text length 5
|
||||
expect(isCursorAtEnd(10, 5)).toBe(true) // cursor beyond text (clamped case)
|
||||
expect(isCursorAtEnd(4, 5)).toBe(false) // cursor before end
|
||||
expect(isCursorAtEnd(0, 0)).toBe(true) // empty text, cursor at start/end
|
||||
})
|
||||
})
|
||||
485
apps/cli/src/__tests__/ScrollArea.test.ts
Normal file
485
apps/cli/src/__tests__/ScrollArea.test.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
/**
|
||||
* Unit tests for ScrollArea component reducer logic
|
||||
*/
|
||||
|
||||
// Since we can't easily test React components without a proper Ink test setup,
|
||||
// we'll test the reducer logic that powers the ScrollArea behavior.
|
||||
|
||||
interface ScrollAreaState {
|
||||
innerHeight: number
|
||||
height: number
|
||||
scrollTop: number
|
||||
autoScroll: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scrollbar handle position and size
|
||||
*/
|
||||
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: "SET_AUTO_SCROLL"; autoScroll: boolean }
|
||||
|
||||
// Copy of the reducer from ScrollArea.tsx for testing
|
||||
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 (state.autoScroll && action.innerHeight > state.innerHeight) {
|
||||
return {
|
||||
...state,
|
||||
innerHeight: action.innerHeight,
|
||||
scrollTop: newMaxScroll,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
innerHeight: action.innerHeight,
|
||||
scrollTop: Math.min(state.scrollTop, newMaxScroll),
|
||||
}
|
||||
}
|
||||
|
||||
case "SET_HEIGHT": {
|
||||
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
|
||||
if (state.autoScroll) {
|
||||
return {
|
||||
...state,
|
||||
height: action.height,
|
||||
scrollTop: newMaxScroll,
|
||||
}
|
||||
}
|
||||
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)
|
||||
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)
|
||||
return {
|
||||
...state,
|
||||
scrollTop: newScrollTop,
|
||||
autoScroll: newScrollTop >= maxScroll,
|
||||
}
|
||||
}
|
||||
|
||||
case "SCROLL_TO_BOTTOM":
|
||||
return {
|
||||
...state,
|
||||
scrollTop: maxScroll,
|
||||
autoScroll: true,
|
||||
}
|
||||
|
||||
case "SET_AUTO_SCROLL":
|
||||
return {
|
||||
...state,
|
||||
autoScroll: action.autoScroll,
|
||||
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
describe("ScrollArea reducer", () => {
|
||||
const initialState: ScrollAreaState = {
|
||||
innerHeight: 0,
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
|
||||
describe("SET_INNER_HEIGHT", () => {
|
||||
it("should update inner height", () => {
|
||||
const state = reducer(initialState, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
|
||||
expect(state.innerHeight).toBe(20)
|
||||
})
|
||||
|
||||
it("should auto-scroll to bottom when content grows and autoScroll is enabled", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 15,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
|
||||
expect(newState.innerHeight).toBe(25)
|
||||
// maxScroll = 25 - 10 = 15
|
||||
expect(newState.scrollTop).toBe(15)
|
||||
})
|
||||
|
||||
it("should NOT auto-scroll when autoScroll is disabled", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 15,
|
||||
scrollTop: 3,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
|
||||
expect(newState.innerHeight).toBe(25)
|
||||
expect(newState.scrollTop).toBe(3) // Unchanged
|
||||
})
|
||||
|
||||
it("should NOT auto-scroll when content grows if autoScroll is disabled (picker use case)", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 5,
|
||||
scrollTop: 0,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
|
||||
expect(newState.innerHeight).toBe(20)
|
||||
// scrollTop should remain at 0, not jump to bottom
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should clamp scrollTop when content shrinks", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 30,
|
||||
scrollTop: 15,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 15 })
|
||||
// maxScroll = 15 - 10 = 5, scrollTop was 15 which is > 5
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SET_HEIGHT", () => {
|
||||
it("should update viewport height", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 20,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
expect(newState.height).toBe(15)
|
||||
})
|
||||
|
||||
it("should scroll to bottom when autoScroll is enabled and viewport changes", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20, // at bottom
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
// maxScroll = 30 - 15 = 15
|
||||
expect(newState.scrollTop).toBe(15)
|
||||
})
|
||||
|
||||
it("should clamp scrollTop when viewport grows", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 20,
|
||||
height: 10,
|
||||
scrollTop: 10, // maxScroll was 10
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
// maxScroll = 20 - 15 = 5
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_DOWN", () => {
|
||||
it("should scroll down by 1 by default", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(6)
|
||||
})
|
||||
|
||||
it("should scroll down by specified amount", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 5 })
|
||||
expect(newState.scrollTop).toBe(10)
|
||||
})
|
||||
|
||||
it("should not scroll past maxScroll", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 18,
|
||||
autoScroll: false,
|
||||
}
|
||||
// maxScroll = 30 - 10 = 20
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 10 })
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
})
|
||||
|
||||
it("should re-enable autoScroll when reaching bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 19,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_UP", () => {
|
||||
it("should scroll up by 1 by default", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 10,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP" })
|
||||
expect(newState.scrollTop).toBe(9)
|
||||
})
|
||||
|
||||
it("should scroll up by specified amount", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 10,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP", amount: 5 })
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
|
||||
it("should not scroll past 0", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 3,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP", amount: 10 })
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should disable autoScroll when scrolling up from bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20, // at bottom
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP" })
|
||||
expect(newState.scrollTop).toBe(19)
|
||||
expect(newState.autoScroll).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_TO_BOTTOM", () => {
|
||||
it("should scroll to bottom and enable autoScroll", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
|
||||
expect(newState.scrollTop).toBe(20) // maxScroll
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SET_AUTO_SCROLL", () => {
|
||||
it("should enable autoScroll and scroll to bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: true })
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
expect(newState.scrollTop).toBe(20) // scrolled to bottom
|
||||
})
|
||||
|
||||
it("should disable autoScroll without changing scrollTop", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: false })
|
||||
expect(newState.autoScroll).toBe(false)
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle content smaller than viewport", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 5, // smaller than viewport
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
const downState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(downState.scrollTop).toBe(0) // maxScroll is 0
|
||||
|
||||
const bottomState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
|
||||
expect(bottomState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 0,
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateScrollbar", () => {
|
||||
it("should return full height handle when content fits in viewport", () => {
|
||||
const result = calculateScrollbar(10, 5, 0)
|
||||
expect(result.handleHeight).toBe(10)
|
||||
expect(result.handleStart).toBe(0)
|
||||
expect(result.maxScroll).toBe(0)
|
||||
})
|
||||
|
||||
it("should return full height handle when content equals viewport", () => {
|
||||
const result = calculateScrollbar(10, 10, 0)
|
||||
expect(result.handleHeight).toBe(10)
|
||||
expect(result.handleStart).toBe(0)
|
||||
expect(result.maxScroll).toBe(0)
|
||||
})
|
||||
|
||||
it("should calculate handle height proportional to content ratio", () => {
|
||||
// Viewport is half of content, handle should be ~half of viewport
|
||||
const result = calculateScrollbar(10, 20, 0)
|
||||
expect(result.handleHeight).toBe(5) // 10 / 20 * 10 = 5
|
||||
expect(result.maxScroll).toBe(10)
|
||||
})
|
||||
|
||||
it("should position handle at top when scrollTop is 0", () => {
|
||||
const result = calculateScrollbar(10, 20, 0)
|
||||
expect(result.handleStart).toBe(0)
|
||||
})
|
||||
|
||||
it("should position handle at bottom when scrolled to max", () => {
|
||||
// Viewport 10, content 20, maxScroll = 10
|
||||
// Handle height = 5, track space = 10 - 5 = 5
|
||||
// At max scroll, handle should be at position 5
|
||||
const result = calculateScrollbar(10, 20, 10)
|
||||
expect(result.handleStart).toBe(5)
|
||||
})
|
||||
|
||||
it("should position handle in middle when scrolled halfway", () => {
|
||||
// Viewport 10, content 20, maxScroll = 10
|
||||
// Handle height = 5, track space = 5
|
||||
// At scroll 5 (50%), handle should be at position 2-3
|
||||
const result = calculateScrollbar(10, 20, 5)
|
||||
expect(result.handleStart).toBe(3) // Math.round(0.5 * 5) = 3
|
||||
})
|
||||
|
||||
it("should enforce minimum handle height of 1", () => {
|
||||
// Very large content relative to viewport
|
||||
const result = calculateScrollbar(10, 1000, 0)
|
||||
expect(result.handleHeight).toBe(1) // Math.max(1, Math.round(10/1000 * 10)) = 1
|
||||
})
|
||||
|
||||
it("should handle small viewports", () => {
|
||||
const result = calculateScrollbar(3, 10, 0)
|
||||
expect(result.handleHeight).toBe(1) // Math.round(3/10 * 3) = 1
|
||||
expect(result.maxScroll).toBe(7)
|
||||
})
|
||||
|
||||
it("should handle edge case where scrollTop exceeds maxScroll", () => {
|
||||
// This shouldn't happen in practice, but test for robustness
|
||||
const result = calculateScrollbar(10, 20, 15) // maxScroll is 10
|
||||
// scrollRatio = 15/10 = 1.5, but handleStart should be clamped by trackSpace
|
||||
expect(result.handleStart).toBe(8) // Math.round(1.5 * 5) = 8 (will be past track but shows calculation)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper function that mirrors the scrollbar visibility logic from ScrollArea.tsx
|
||||
* This is used to test the visibility behavior without needing to render the component.
|
||||
*/
|
||||
function shouldShowScrollbar(showScrollbar: boolean, maxScroll: number, isActive: boolean): boolean {
|
||||
// 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
|
||||
return showScrollbar && (maxScroll > 0 || isActive)
|
||||
}
|
||||
|
||||
describe("scrollbar visibility", () => {
|
||||
it("should show scrollbar when there is content to scroll (regardless of focus)", () => {
|
||||
// When maxScroll > 0, scrollbar should show regardless of isActive
|
||||
expect(shouldShowScrollbar(true, 10, true)).toBe(true)
|
||||
expect(shouldShowScrollbar(true, 10, false)).toBe(true)
|
||||
})
|
||||
|
||||
it("should show scrollbar when focused, even if nothing to scroll", () => {
|
||||
// When isActive is true but maxScroll is 0, scrollbar should show for focus indication
|
||||
expect(shouldShowScrollbar(true, 0, true)).toBe(true)
|
||||
})
|
||||
|
||||
it("should hide scrollbar when not focused and nothing to scroll", () => {
|
||||
// Only hide when both: not focused AND nothing to scroll
|
||||
expect(shouldShowScrollbar(true, 0, false)).toBe(false)
|
||||
})
|
||||
|
||||
it("should respect showScrollbar prop", () => {
|
||||
// When showScrollbar is false, never show scrollbar
|
||||
expect(shouldShowScrollbar(false, 10, true)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 0, true)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 10, false)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 0, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
197
apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts
Normal file
197
apps/cli/src/__tests__/autocomplete/FileTrigger.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import {
|
||||
createFileTrigger,
|
||||
toFileResult,
|
||||
type FileResult,
|
||||
} from "../../ui/components/autocomplete/triggers/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 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 detect @ trigger even with empty query", () => {
|
||||
const result = trigger.detectTrigger("hello @")
|
||||
|
||||
expect(result).toEqual({
|
||||
query: "",
|
||||
triggerIndex: 6,
|
||||
})
|
||||
})
|
||||
|
||||
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 ")
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
165
apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts
Normal file
165
apps/cli/src/__tests__/autocomplete/ModeTrigger.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
createModeTrigger,
|
||||
toModeResult,
|
||||
type ModeResult,
|
||||
} from "../../ui/components/autocomplete/triggers/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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
161
apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts
Normal file
161
apps/cli/src/__tests__/autocomplete/SlashCommandTrigger.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { describe, it, expect, vi } from "vitest"
|
||||
import {
|
||||
createSlashCommandTrigger,
|
||||
toSlashCommandResult,
|
||||
type SlashCommandResult,
|
||||
} from "../../ui/components/autocomplete/triggers/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
// pnpm --filter @roo-code/cli test src/__tests__/extension-host.test.ts
|
||||
|
||||
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
|
||||
import { EventEmitter } from "events"
|
||||
import type { ProviderName } from "@roo-code/types"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
import type { ProviderName, WebviewMessage } from "@roo-code/types"
|
||||
|
||||
import { ExtensionHost, type ExtensionHostOptions } from "../extension-host.js"
|
||||
|
||||
vi.mock("@roo-code/vscode-shim", () => ({
|
||||
createVSCodeAPI: vi.fn(() => ({
|
||||
|
|
@ -369,15 +374,15 @@ describe("ExtensionHost", () => {
|
|||
const emitSpy = vi.spyOn(host, "emit")
|
||||
|
||||
// Queue messages before ready
|
||||
host.sendToExtension({ type: "test1" })
|
||||
host.sendToExtension({ type: "test2" })
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
host.sendToExtension({ type: "requestCommands" })
|
||||
|
||||
// Mark ready (should flush)
|
||||
host.markWebviewReady()
|
||||
|
||||
// Check that webviewMessage events were emitted for pending messages
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test1" })
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "test2" })
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestModes" })
|
||||
expect(emitSpy).toHaveBeenCalledWith("webviewMessage", { type: "requestCommands" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -385,7 +390,7 @@ describe("ExtensionHost", () => {
|
|||
describe("sendToExtension", () => {
|
||||
it("should queue message when webview not ready", () => {
|
||||
const host = createTestHost()
|
||||
const message = { type: "test" }
|
||||
const message: WebviewMessage = { type: "requestModes" }
|
||||
|
||||
host.sendToExtension(message)
|
||||
|
||||
|
|
@ -396,7 +401,7 @@ describe("ExtensionHost", () => {
|
|||
it("should emit webviewMessage event when webview is ready", () => {
|
||||
const host = createTestHost()
|
||||
const emitSpy = vi.spyOn(host, "emit")
|
||||
const message = { type: "test" }
|
||||
const message: WebviewMessage = { type: "requestModes" }
|
||||
|
||||
host.markWebviewReady()
|
||||
host.sendToExtension(message)
|
||||
|
|
@ -408,7 +413,7 @@ describe("ExtensionHost", () => {
|
|||
const host = createTestHost()
|
||||
|
||||
host.markWebviewReady()
|
||||
host.sendToExtension({ type: "test" })
|
||||
host.sendToExtension({ type: "requestModes" })
|
||||
|
||||
const pending = getPrivate<unknown[]>(host, "pendingMessages")
|
||||
expect(pending).toHaveLength(0)
|
||||
|
|
@ -433,24 +438,6 @@ describe("ExtensionHost", () => {
|
|||
|
||||
expect(handleMsgUpdatedSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should route action messages to handleActionMessage", () => {
|
||||
const host = createTestHost()
|
||||
const handleActionSpy = spyOnPrivate(host, "handleActionMessage")
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "action", action: "test" })
|
||||
|
||||
expect(handleActionSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should route invoke messages to handleInvokeMessage", () => {
|
||||
const host = createTestHost()
|
||||
const handleInvokeSpy = spyOnPrivate(host, "handleInvokeMessage")
|
||||
|
||||
callPrivate(host, "handleExtensionMessage", { type: "invoke", invoke: "test" })
|
||||
|
||||
expect(handleInvokeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleSayMessage", () => {
|
||||
|
|
@ -810,7 +797,7 @@ describe("ExtensionHost", () => {
|
|||
callPrivate(host, "handleFollowupQuestionWithTimeout", 123, text)
|
||||
|
||||
// Should show prompt with timeout hint
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 10s"))
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith(expect.stringContaining("auto-select in 60s"))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1161,4 +1148,207 @@ describe("ExtensionHost", () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleStateMessage - mode tracking", () => {
|
||||
let host: ExtensionHost
|
||||
|
||||
beforeEach(() => {
|
||||
host = createTestHost({
|
||||
mode: "code",
|
||||
apiProvider: "anthropic",
|
||||
apiKey: "test-key",
|
||||
model: "test-model",
|
||||
})
|
||||
// Mock process.stdout.write which is used by output()
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(() => true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should track current mode when state updates with a mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "code", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("code")
|
||||
|
||||
// Second state update should update tracked mode
|
||||
callPrivate(host, "handleStateMessage", { type: "state", state: { mode: "architect", clineMessages: [] } })
|
||||
expect(getPrivate(host, "currentMode")).toBe("architect")
|
||||
})
|
||||
|
||||
it("should not change current mode when state has no mode", () => {
|
||||
// Initial state update establishes current mode
|
||||
callPrivate(host, "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("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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
103
apps/cli/src/__tests__/globalCommands.test.ts
Normal file
103
apps/cli/src/__tests__/globalCommands.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
GLOBAL_COMMANDS,
|
||||
getGlobalCommand,
|
||||
getGlobalCommandsForAutocomplete,
|
||||
type GlobalCommand,
|
||||
type GlobalCommandAction,
|
||||
} 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
238
apps/cli/src/__tests__/historyStorage.test.ts
Normal file
238
apps/cli/src/__tests__/historyStorage.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import {
|
||||
getHistoryFilePath,
|
||||
loadHistory,
|
||||
saveHistory,
|
||||
addToHistory,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
} from "../utils/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)
|
||||
})
|
||||
})
|
||||
})
|
||||
163
apps/cli/src/__tests__/useInputHistory.test.ts
Normal file
163
apps/cli/src/__tests__/useInputHistory.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import * as historyStorage from "../utils/historyStorage.js"
|
||||
|
||||
vi.mock("../utils/historyStorage.js")
|
||||
|
||||
// Track state and callbacks for testing.
|
||||
let mockState: Record<string, unknown> = {}
|
||||
let effectCallbacks: Array<() => void | (() => void)> = []
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useState: vi.fn((initial: unknown) => {
|
||||
const key = `state_${Object.keys(mockState).length}`
|
||||
if (!(key in mockState)) {
|
||||
mockState[key] = initial
|
||||
}
|
||||
return [
|
||||
mockState[key],
|
||||
(newValue: unknown) => {
|
||||
if (typeof newValue === "function") {
|
||||
mockState[key] = (newValue as (prev: unknown) => unknown)(mockState[key])
|
||||
} else {
|
||||
mockState[key] = newValue
|
||||
}
|
||||
},
|
||||
]
|
||||
}),
|
||||
useEffect: vi.fn((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
}),
|
||||
useCallback: vi.fn((callback: unknown) => callback),
|
||||
useRef: vi.fn((initial: unknown) => ({ current: initial })),
|
||||
}))
|
||||
|
||||
describe("useInputHistory", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockState = {}
|
||||
effectCallbacks = []
|
||||
|
||||
// Default mock for loadHistory
|
||||
vi.mocked(historyStorage.loadHistory).mockResolvedValue([])
|
||||
vi.mocked(historyStorage.addToHistory).mockImplementation(async (entry) => [entry])
|
||||
})
|
||||
|
||||
describe("historyStorage functions", () => {
|
||||
it("loadHistory should be called when hook effect runs", async () => {
|
||||
vi.mocked(historyStorage.loadHistory).mockResolvedValue(["entry1", "entry2"])
|
||||
|
||||
// Import the hook (this triggers the module initialization)
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
useInputHistory()
|
||||
|
||||
// Run the effect callbacks
|
||||
for (const cb of effectCallbacks) {
|
||||
cb()
|
||||
}
|
||||
|
||||
expect(historyStorage.loadHistory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addToHistory should be called with trimmed entry", async () => {
|
||||
vi.mocked(historyStorage.addToHistory).mockResolvedValue(["new entry"])
|
||||
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry(" new entry ")
|
||||
|
||||
expect(historyStorage.addToHistory).toHaveBeenCalledWith("new entry")
|
||||
})
|
||||
|
||||
it("addToHistory should not be called for empty entries", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry("")
|
||||
|
||||
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addToHistory should not be called for whitespace-only entries", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry(" ")
|
||||
|
||||
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("navigation logic", () => {
|
||||
it("should have initial state with no history value", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
// Initial state should have null history value (not browsing)
|
||||
expect(result.historyValue).toBeNull()
|
||||
expect(result.isBrowsing).toBe(false)
|
||||
})
|
||||
|
||||
it("should export navigateUp and navigateDown functions for manual navigation", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(typeof result.navigateUp).toBe("function")
|
||||
expect(typeof result.navigateDown).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetBrowsing", () => {
|
||||
it("should be a function", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(typeof result.resetBrowsing).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("return value structure", () => {
|
||||
it("should return the expected interface", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(result).toHaveProperty("addEntry")
|
||||
expect(result).toHaveProperty("historyValue")
|
||||
expect(result).toHaveProperty("isBrowsing")
|
||||
expect(result).toHaveProperty("resetBrowsing")
|
||||
expect(result).toHaveProperty("history")
|
||||
expect(result).toHaveProperty("draft")
|
||||
expect(result).toHaveProperty("navigateUp")
|
||||
expect(result).toHaveProperty("navigateDown")
|
||||
|
||||
expect(typeof result.addEntry).toBe("function")
|
||||
expect(typeof result.resetBrowsing).toBe("function")
|
||||
expect(typeof result.navigateUp).toBe("function")
|
||||
expect(typeof result.navigateDown).toBe("function")
|
||||
expect(Array.isArray(result.history)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("historyStorage integration", () => {
|
||||
// Test the actual historyStorage functions directly
|
||||
// These are more reliable than hook tests with mocked React
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("MAX_HISTORY_ENTRIES should be 500", async () => {
|
||||
const { MAX_HISTORY_ENTRIES } = await import("../utils/historyStorage.js")
|
||||
expect(MAX_HISTORY_ENTRIES).toBe(500)
|
||||
})
|
||||
|
||||
it("getHistoryFilePath should return path in ~/.roo directory", async () => {
|
||||
// Un-mock for this test
|
||||
vi.doUnmock("../utils/historyStorage.js")
|
||||
const { getHistoryFilePath } = await import("../utils/historyStorage.js")
|
||||
|
||||
const path = getHistoryFilePath()
|
||||
expect(path).toContain(".roo")
|
||||
expect(path).toContain("cli-history.json")
|
||||
})
|
||||
})
|
||||
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
62
apps/cli/src/globalCommands.ts
Normal file
62
apps/cli/src/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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -4,8 +4,10 @@
|
|||
|
||||
import { Command } from "commander"
|
||||
import fs from "fs"
|
||||
import { createRequire } from "module"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { createElement } from "react"
|
||||
|
||||
import {
|
||||
type ProviderName,
|
||||
|
|
@ -28,13 +30,20 @@ const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
// Read version from package.json
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = require("../package.json")
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0")
|
||||
program
|
||||
.name("roo")
|
||||
.description("Roo Code CLI - Run the Roo Code agent from the command line")
|
||||
.version(packageJson.version)
|
||||
|
||||
program
|
||||
.argument("<prompt>", "The prompt/task to execute")
|
||||
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
|
||||
.argument("[workspace]", "Workspace path to operate in", process.cwd())
|
||||
.option("-P, --prompt <prompt>", "The prompt/task to execute (optional in TUI mode)")
|
||||
.option("-e, --extension <path>", "Path to the extension bundle directory")
|
||||
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
|
||||
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
|
||||
|
|
@ -49,11 +58,13 @@ program
|
|||
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||
DEFAULTS.reasoningEffort,
|
||||
)
|
||||
.option("--ephemeral", "Run without persisting state (uses temporary storage)", false)
|
||||
.option("--no-tui", "Disable TUI, use plain text output")
|
||||
.action(
|
||||
async (
|
||||
prompt: string,
|
||||
workspaceArg: string,
|
||||
options: {
|
||||
workspace: string
|
||||
prompt?: string
|
||||
extension?: string
|
||||
verbose: boolean
|
||||
debug: boolean
|
||||
|
|
@ -64,6 +75,8 @@ program
|
|||
model?: string
|
||||
mode?: string
|
||||
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
||||
ephemeral: boolean
|
||||
tui: boolean
|
||||
},
|
||||
) => {
|
||||
// Default is quiet mode - suppress VSCode shim logs unless verbose
|
||||
|
|
@ -79,7 +92,7 @@ program
|
|||
|
||||
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
||||
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
||||
const workspacePath = path.resolve(options.workspace)
|
||||
const workspacePath = path.resolve(workspaceArg)
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(
|
||||
|
|
@ -106,57 +119,147 @@ program
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`[CLI] Mode: ${options.mode || "default"}`)
|
||||
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
|
||||
console.log(`[CLI] Provider: ${options.provider}`)
|
||||
console.log(`[CLI] Model: ${options.model || "default"}`)
|
||||
console.log(`[CLI] Workspace: ${workspacePath}`)
|
||||
// TUI is enabled by default, disabled with --no-tui
|
||||
// TUI requires raw mode support (proper TTY for stdin and stdout)
|
||||
const canUseTui = process.stdin.isTTY && process.stdout.isTTY
|
||||
const useTui = options.tui && canUseTui
|
||||
|
||||
const host = new ExtensionHost({
|
||||
mode: options.mode || DEFAULTS.mode,
|
||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||
apiProvider: options.provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULTS.model,
|
||||
workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
verbose: options.debug,
|
||||
quiet: !options.verbose && !options.debug,
|
||||
nonInteractive: options.yes,
|
||||
})
|
||||
if (options.tui && !canUseTui) {
|
||||
console.log("[CLI] TUI disabled (no TTY support), falling back to plain text mode")
|
||||
}
|
||||
|
||||
// Handle SIGINT (Ctrl+C)
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
// Handle SIGTERM
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
await host.runTask(prompt)
|
||||
await host.dispose()
|
||||
|
||||
if (options.exitOnComplete) {
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (options.debug && error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
await host.dispose()
|
||||
// In plain text mode, prompt is required
|
||||
if (!useTui && !options.prompt) {
|
||||
console.error("[CLI] Error: prompt is required in plain text mode")
|
||||
console.error("[CLI] Usage: roo [workspace] -P <prompt> [options]")
|
||||
console.error("[CLI] Use TUI mode (without --no-tui) for interactive input")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (useTui) {
|
||||
// TUI Mode - render Ink application
|
||||
try {
|
||||
const { render } = await import("ink")
|
||||
const { App } = await import("./ui/App.js")
|
||||
|
||||
// Create extension host factory for dependency injection
|
||||
const createExtensionHost = (opts: {
|
||||
mode: string
|
||||
reasoningEffort?: string
|
||||
apiProvider: string
|
||||
apiKey: string
|
||||
model: string
|
||||
workspacePath: string
|
||||
extensionPath: string
|
||||
verbose: boolean
|
||||
quiet: boolean
|
||||
nonInteractive: boolean
|
||||
disableOutput: boolean
|
||||
ephemeral?: boolean
|
||||
}) => {
|
||||
return new ExtensionHost({
|
||||
mode: opts.mode,
|
||||
reasoningEffort:
|
||||
opts.reasoningEffort === "unspecified"
|
||||
? undefined
|
||||
: (opts.reasoningEffort as ReasoningEffortExtended | "disabled" | undefined),
|
||||
apiProvider: opts.apiProvider as ProviderName,
|
||||
apiKey: opts.apiKey,
|
||||
model: opts.model,
|
||||
workspacePath: opts.workspacePath,
|
||||
extensionPath: opts.extensionPath,
|
||||
verbose: opts.verbose,
|
||||
quiet: opts.quiet,
|
||||
nonInteractive: opts.nonInteractive,
|
||||
disableOutput: opts.disableOutput,
|
||||
ephemeral: opts.ephemeral,
|
||||
})
|
||||
}
|
||||
|
||||
render(
|
||||
createElement(App, {
|
||||
initialPrompt: options.prompt || "", // Empty string if no prompt - user will type in TUI
|
||||
workspacePath: workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
apiProvider: options.provider,
|
||||
apiKey: apiKey,
|
||||
model: options.model || DEFAULTS.model,
|
||||
mode: options.mode || DEFAULTS.mode,
|
||||
nonInteractive: options.yes,
|
||||
verbose: options.verbose,
|
||||
debug: options.debug,
|
||||
exitOnComplete: options.exitOnComplete,
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
ephemeral: options.ephemeral,
|
||||
createExtensionHost: createExtensionHost,
|
||||
version: packageJson.version,
|
||||
}),
|
||||
{
|
||||
exitOnCtrlC: false, // Handle Ctrl+C in App component for double-press exit
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[CLI] Failed to start TUI:", error instanceof Error ? error.message : String(error))
|
||||
if (options.debug && error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
// Plain text mode (existing behavior)
|
||||
console.log(`[CLI] Mode: ${options.mode || "default"}`)
|
||||
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
|
||||
console.log(`[CLI] Provider: ${options.provider}`)
|
||||
console.log(`[CLI] Model: ${options.model || "default"}`)
|
||||
console.log(`[CLI] Workspace: ${workspacePath}`)
|
||||
|
||||
const host = new ExtensionHost({
|
||||
mode: options.mode || DEFAULTS.mode,
|
||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||
apiProvider: options.provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULTS.model,
|
||||
workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
verbose: options.debug,
|
||||
quiet: !options.verbose && !options.debug,
|
||||
nonInteractive: options.yes,
|
||||
ephemeral: options.ephemeral,
|
||||
})
|
||||
|
||||
// Handle SIGINT (Ctrl+C)
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
// Handle SIGTERM
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
await host.runTask(options.prompt!) // prompt is guaranteed non-null in plain text mode
|
||||
await host.dispose()
|
||||
|
||||
if (options.exitOnComplete) {
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (options.debug && error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
1521
apps/cli/src/ui/App.tsx
Normal file
1521
apps/cli/src/ui/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
109
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal file
109
apps/cli/src/ui/components/ChatHistoryItem.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { memo } from "react"
|
||||
import { Box, Newline, Text } from "ink"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import type { TUIMessage } from "../types.js"
|
||||
import TodoDisplay from "./TodoDisplay.js"
|
||||
|
||||
/**
|
||||
* 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, "")
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<TodoDisplay todos={message.todos} previousTodos={message.previousTodos} showProgress={true} />
|
||||
<Text>
|
||||
<Newline />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Sanitize toolDisplayOutput if present, otherwise use already-sanitized content
|
||||
const toolContent = message.toolDisplayOutput ? sanitizeContent(message.toolDisplayOutput) : content
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text bold color={theme.toolHeader}>
|
||||
{`tool - ${message.toolDisplayName || message.toolName || "unknown"}`}
|
||||
</Text>
|
||||
<Text color={theme.toolText}>
|
||||
{toolContent}
|
||||
<Newline />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
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)
|
||||
68
apps/cli/src/ui/components/Header.tsx
Normal file
68
apps/cli/src/ui/components/Header.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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 "../utils/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>
|
||||
{showMetrics && (
|
||||
<Box marginTop={1}>
|
||||
<MetricsDisplay tokenUsage={tokenUsage} contextWindow={contextWindow} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Inline horizontal line using the same columns value */}
|
||||
<Text color={theme.borderColor}>{"─".repeat(columns)}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Header)
|
||||
144
apps/cli/src/ui/components/Icon.tsx
Normal file
144
apps/cli/src/ui/components/Icon.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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" | "check" | "cross" | "arrow-right" | "bullet" | "spinner"
|
||||
|
||||
/**
|
||||
* Icon definitions with Nerd Font glyph and ASCII fallback.
|
||||
* Nerd Font glyphs are surrogate pairs (2 JS chars, 1 visual char).
|
||||
*/
|
||||
const ICONS: Record<IconName, { nerd: string; fallback: string }> = {
|
||||
folder: { nerd: "\udb80\ude4b", fallback: "▼" },
|
||||
file: { nerd: "\udb80\ude14", fallback: "●" },
|
||||
check: { nerd: "\uf00c", fallback: "✓" },
|
||||
cross: { nerd: "\uf00d", fallback: "✗" },
|
||||
"arrow-right": { nerd: "\uf061", fallback: "→" },
|
||||
bullet: { nerd: "\uf111", fallback: "•" },
|
||||
spinner: { nerd: "\uf110", fallback: "*" },
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// DEBUG: Log icon selection
|
||||
console.error(
|
||||
`DEBUG Icon: name=${name}, shouldUseNerdFont=${shouldUseNerdFont}, envOverride=${process.env.ROOCODE_NERD_FONT}, icon.length=${icon.length}`,
|
||||
)
|
||||
|
||||
// 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)
|
||||
69
apps/cli/src/ui/components/MetricsDisplay.tsx
Normal file
69
apps/cli/src/ui/components/MetricsDisplay.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { memo } from "react"
|
||||
import { Text, Box } from "ink"
|
||||
|
||||
import type { TokenUsage } from "@roo-code/types"
|
||||
|
||||
import * as theme from "../utils/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 │ Context: [████████░░░░] 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>
|
||||
<Text color={theme.dimText}>Context: </Text>
|
||||
<ProgressBar value={contextTokens} max={contextWindow} width={12} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MetricsDisplay)
|
||||
export { formatNumber, formatCost }
|
||||
490
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal file
490
apps/cli/src/ui/components/MultilineTextInput.tsx
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
/**
|
||||
* 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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Ctrl+C: ignore (handled elsewhere)
|
||||
if (key.ctrl && input === "c") {
|
||||
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 "../utils/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 "../utils/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 "../utils/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)
|
||||
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 "../utils/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)
|
||||
179
apps/cli/src/ui/components/TodoDisplay.tsx
Normal file
179
apps/cli/src/ui/components/TodoDisplay.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { memo } from "react"
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import type { TodoItem } from "@roo-code/types"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import ProgressBar from "./ProgressBar.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 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: "TODO List Updated") */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TodoDisplay component for CLI
|
||||
*
|
||||
* Renders a beautiful TODO list visualization with:
|
||||
* - Status icons (✓ completed, → in progress, ○ pending)
|
||||
* - Color-coded items based on status
|
||||
* - Progress bar showing completion percentage
|
||||
* - Optional diff mode showing only changed items
|
||||
*
|
||||
* Visual example:
|
||||
* ```
|
||||
* ┌─ TODO List Updated ──────────────────────────────┐
|
||||
* │ ✓ Analyze requirements │
|
||||
* │ ✓ Design architecture │
|
||||
* │ → Implement core logic │
|
||||
* │ ○ Write tests │
|
||||
* │ ○ Update documentation │
|
||||
* │ [████████░░░░░░░░] 2/5 completed │
|
||||
* └──────────────────────────────────────────────────┘
|
||||
* ```
|
||||
*/
|
||||
function TodoDisplay({
|
||||
todos,
|
||||
previousTodos = [],
|
||||
showProgress = true,
|
||||
showChangesOnly = false,
|
||||
title = "TODO List Updated",
|
||||
}: 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
|
||||
const inProgressCount = todos.filter((t) => t.status === "in_progress").length
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text color={theme.toolHeader} bold>
|
||||
☑ {title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Border top */}
|
||||
<Box>
|
||||
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* TODO items */}
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
{displayTodos.map((todo, index) => {
|
||||
const icon = STATUS_ICONS[todo.status] || STATUS_ICONS.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}`}>
|
||||
<Text color={color}>
|
||||
{icon} {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>
|
||||
|
||||
{/* Progress bar and stats */}
|
||||
{showProgress && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={theme.borderColor}>{"─".repeat(50)}</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={1}>
|
||||
<ProgressBar value={completedCount} max={totalCount} width={16} />
|
||||
<Text color={theme.dimText}>
|
||||
{" "}
|
||||
{completedCount}/{totalCount} completed
|
||||
{inProgressCount > 0 && `, ${inProgressCount} in progress`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TodoDisplay)
|
||||
234
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal file
234
apps/cli/src/ui/components/__tests__/ChatHistoryItem.test.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { render } from "ink-testing-library"
|
||||
|
||||
import type { TUIMessage } from "../../types.js"
|
||||
import ChatHistoryItem from "../ChatHistoryItem.js"
|
||||
|
||||
describe("ChatHistoryItem", () => {
|
||||
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", () => {
|
||||
const message: TUIMessage = {
|
||||
id: "4",
|
||||
role: "tool",
|
||||
content: '{\n\t"key": "value"\n}',
|
||||
toolName: "read_file",
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ChatHistoryItem message={message} />)
|
||||
const output = lastFrame()
|
||||
|
||||
expect(output).toContain(' "key": "value"')
|
||||
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 tool name", () => {
|
||||
const message: TUIMessage = {
|
||||
id: "4",
|
||||
role: "tool",
|
||||
content: "Output",
|
||||
toolName: "read_file",
|
||||
toolDisplayName: "Read File",
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ChatHistoryItem message={message} />)
|
||||
const output = lastFrame()
|
||||
|
||||
expect(output).toContain("tool - Read File")
|
||||
expect(output).toContain("Output")
|
||||
})
|
||||
|
||||
it("uses fallback content when message.content is empty", () => {
|
||||
const message: TUIMessage = {
|
||||
id: "5",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ChatHistoryItem message={message} />)
|
||||
const output = lastFrame()
|
||||
|
||||
expect(output).toContain("...")
|
||||
})
|
||||
|
||||
it("returns null for unknown role", () => {
|
||||
const message = {
|
||||
id: "6",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
role: "unknown" as any,
|
||||
content: "Test",
|
||||
}
|
||||
|
||||
const { lastFrame } = render(<ChatHistoryItem message={message} />)
|
||||
expect(lastFrame()).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
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 surrogate pairs (length 2)
|
||||
expect(getIconChar("folder").length).toBe(2)
|
||||
expect(getIconChar("file").length).toBe(2)
|
||||
})
|
||||
|
||||
it("should respect useNerdFont override", () => {
|
||||
process.env.ROOCODE_NERD_FONT = "1"
|
||||
resetNerdFontCache()
|
||||
|
||||
// Force fallback
|
||||
expect(getIconChar("folder", false)).toBe("▼")
|
||||
|
||||
process.env.ROOCODE_NERD_FONT = "0"
|
||||
resetNerdFontCache()
|
||||
|
||||
// Force Nerd Font
|
||||
expect(getIconChar("folder", true).length).toBe(2)
|
||||
})
|
||||
|
||||
it("should return empty string for unknown icon", () => {
|
||||
// @ts-expect-error - testing invalid icon name
|
||||
expect(getIconChar("unknown")).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
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]")
|
||||
})
|
||||
})
|
||||
138
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal file
138
apps/cli/src/ui/components/__tests__/TodoDisplay.test.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { render } from "ink-testing-library"
|
||||
|
||||
import type { TodoItem } from "@roo-code/types"
|
||||
|
||||
import TodoDisplay from "../TodoDisplay.js"
|
||||
|
||||
describe("TodoDisplay", () => {
|
||||
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
|
||||
expect(output).toContain("TODO List Updated")
|
||||
|
||||
// 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
|
||||
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 stats
|
||||
expect(output).toContain("2/5 completed")
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
expect(output).toContain("1/4 completed")
|
||||
expect(output).toContain("2 in progress")
|
||||
})
|
||||
})
|
||||
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>
|
||||
)
|
||||
}
|
||||
62
apps/cli/src/ui/components/autocomplete/index.ts
Normal file
62
apps/cli/src/ui/components/autocomplete/index.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* 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 { AutocompleteInput, type AutocompleteInputProps, type AutocompleteInputHandle } from "./AutocompleteInput.js"
|
||||
export { PickerSelect, type PickerSelectProps } from "./PickerSelect.js"
|
||||
|
||||
// Hook
|
||||
export { useAutocompletePicker } from "./useAutocompletePicker.js"
|
||||
|
||||
// Types
|
||||
export type {
|
||||
AutocompleteItem,
|
||||
AutocompleteTrigger,
|
||||
AutocompletePickerState,
|
||||
AutocompletePickerActions,
|
||||
TriggerDetectionResult,
|
||||
} from "./types.js"
|
||||
|
||||
// Triggers
|
||||
export {
|
||||
createFileTrigger,
|
||||
toFileResult,
|
||||
type FileResult,
|
||||
type FileTriggerConfig,
|
||||
createSlashCommandTrigger,
|
||||
toSlashCommandResult,
|
||||
type SlashCommandResult,
|
||||
type SlashCommandTriggerConfig,
|
||||
createModeTrigger,
|
||||
toModeResult,
|
||||
type ModeResult,
|
||||
type ModeTriggerConfig,
|
||||
createHelpTrigger,
|
||||
type HelpShortcutResult,
|
||||
} from "./triggers/index.js"
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { render } from "ink-testing-library"
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import { createFileTrigger, toFileResult } from "./FileTrigger.js"
|
||||
|
||||
describe("FileTrigger", () => {
|
||||
describe("createFileTrigger", () => {
|
||||
it("should detect @ trigger", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const result = trigger.detectTrigger("@fil")
|
||||
expect(result).toEqual({ query: "fil", triggerIndex: 0 })
|
||||
})
|
||||
|
||||
it("should detect @ trigger in middle of line", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const result = trigger.detectTrigger("some text @fil")
|
||||
expect(result).toEqual({ query: "fil", triggerIndex: 10 })
|
||||
})
|
||||
|
||||
it("should detect @ even without text after it", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const result = trigger.detectTrigger("@")
|
||||
expect(result).toEqual({ query: "", triggerIndex: 0 })
|
||||
})
|
||||
|
||||
it("should not detect @ followed by space", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const result = trigger.detectTrigger("@ ")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should close picker when query contains space", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const result = trigger.detectTrigger("@file name")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should generate correct replacement text for files", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
const item = toFileResult({ path: "src/index.ts", type: "file" })
|
||||
const lineText = "Check @ind"
|
||||
const replacement = trigger.getReplacementText(item, lineText, 6)
|
||||
|
||||
expect(replacement).toBe("Check @/src/index.ts ")
|
||||
})
|
||||
|
||||
it("should generate correct replacement text for folders", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
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 trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it("should render file items correctly", () => {
|
||||
const trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
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 trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
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 trigger = createFileTrigger({
|
||||
onSearch: () => {},
|
||||
getResults: () => [],
|
||||
})
|
||||
|
||||
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]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("toFileResult", () => {
|
||||
it("should convert file search result to FileResult", () => {
|
||||
const result = toFileResult({ path: "src/index.ts", type: "file" })
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "src/index.ts",
|
||||
path: "src/index.ts",
|
||||
type: "file",
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve label", () => {
|
||||
const result = toFileResult({
|
||||
path: "src/index.ts",
|
||||
type: "file",
|
||||
label: "Main entry",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "src/index.ts",
|
||||
path: "src/index.ts",
|
||||
type: "file",
|
||||
label: "Main entry",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
147
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal file
147
apps/cli/src/ui/components/autocomplete/triggers/FileTrigger.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Box, Text } from "ink"
|
||||
import Fuzzysort from "fuzzysort"
|
||||
|
||||
import { Icon } from "../../Icon.js"
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
|
||||
/**
|
||||
* File search result type.
|
||||
* Extends AutocompleteItem with file-specific properties.
|
||||
*/
|
||||
export interface FileResult extends AutocompleteItem {
|
||||
/** File or folder path */
|
||||
path: string
|
||||
/** Whether this is a file or folder */
|
||||
type: "file" | "folder"
|
||||
/** Optional display label */
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { render } from "ink-testing-library"
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
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(6)
|
||||
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 + c")
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
106
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal file
106
apps/cli/src/ui/components/autocomplete/triggers/HelpTrigger.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Box, Text } from "ink"
|
||||
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
|
||||
/**
|
||||
* Help shortcut result type.
|
||||
* Represents a keyboard shortcut or trigger hint.
|
||||
*/
|
||||
export interface HelpShortcutResult extends AutocompleteItem {
|
||||
/** The shortcut key or trigger character */
|
||||
shortcut: string
|
||||
/** Description of what the shortcut does */
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in shortcuts to display in the help menu.
|
||||
*/
|
||||
const HELP_SHORTCUTS: HelpShortcutResult[] = [
|
||||
{ key: "slash", shortcut: "/", description: "for commands" },
|
||||
{ key: "at", shortcut: "@", description: "for file paths" },
|
||||
{ key: "bang", shortcut: "!", description: "for modes" },
|
||||
{ key: "newline", shortcut: "shift + ⏎", description: "for newline" },
|
||||
{ key: "focus", shortcut: "tab", description: "to toggle focus" },
|
||||
{ key: "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), just clear the input
|
||||
if (["newline", "focus", "quit"].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
|
||||
}
|
||||
}
|
||||
128
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal file
128
apps/cli/src/ui/components/autocomplete/triggers/ModeTrigger.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { Box, Text } from "ink"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
|
||||
/**
|
||||
* Mode result type.
|
||||
* Extends AutocompleteItem with mode-specific properties.
|
||||
*/
|
||||
export interface ModeResult extends AutocompleteItem {
|
||||
/** Mode slug (e.g., "code", "architect") */
|
||||
slug: string
|
||||
/** Mode display name */
|
||||
name: string
|
||||
/** Optional description of the mode */
|
||||
description?: string
|
||||
/** Optional icon for the mode */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for creating a mode trigger
|
||||
*/
|
||||
export interface ModeTriggerConfig {
|
||||
/**
|
||||
* Get all available modes for filtering.
|
||||
* Modes are filtered locally using fuzzy search.
|
||||
*/
|
||||
getModes: () => ModeResult[]
|
||||
/**
|
||||
* Maximum number of results to show.
|
||||
* @default 20
|
||||
*/
|
||||
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 ModeResult.
|
||||
* 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,144 @@
|
|||
import { Box, Text } from "ink"
|
||||
import fuzzysort from "fuzzysort"
|
||||
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
|
||||
/**
|
||||
* Slash command result type.
|
||||
* Extends AutocompleteItem with command-specific properties.
|
||||
*/
|
||||
export interface SlashCommandResult extends AutocompleteItem {
|
||||
/** Command name (without the leading /) */
|
||||
name: string
|
||||
/** Optional description of what the command does */
|
||||
description?: string
|
||||
/** Optional hint about command arguments */
|
||||
argumentHint?: string
|
||||
/** Source of the command */
|
||||
source: "global" | "project" | "built-in"
|
||||
/** Action to trigger for CLI global commands (only present for action commands) */
|
||||
action?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for creating a slash command trigger
|
||||
*/
|
||||
export interface SlashCommandTriggerConfig {
|
||||
/**
|
||||
* Get all available commands for filtering.
|
||||
* Commands are filtered locally using fuzzy search.
|
||||
*/
|
||||
getCommands: () => SlashCommandResult[]
|
||||
/**
|
||||
* Maximum number of results to show.
|
||||
* @default 20
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
16
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal file
16
apps/cli/src/ui/components/autocomplete/triggers/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Autocomplete triggers for different trigger patterns.
|
||||
*/
|
||||
|
||||
export { createFileTrigger, toFileResult, type FileResult, type FileTriggerConfig } from "./FileTrigger.js"
|
||||
|
||||
export {
|
||||
createSlashCommandTrigger,
|
||||
toSlashCommandResult,
|
||||
type SlashCommandResult,
|
||||
type SlashCommandTriggerConfig,
|
||||
} from "./SlashCommandTrigger.js"
|
||||
|
||||
export { createModeTrigger, toModeResult, type ModeResult, type ModeTriggerConfig } from "./ModeTrigger.js"
|
||||
|
||||
export { createHelpTrigger, type HelpShortcutResult } from "./HelpTrigger.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]
|
||||
}
|
||||
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
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
39
apps/cli/src/ui/index.ts
Normal file
39
apps/cli/src/ui/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// 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 system
|
||||
export {
|
||||
AutocompleteInput,
|
||||
PickerSelect,
|
||||
useAutocompletePicker,
|
||||
createFileTrigger,
|
||||
createSlashCommandTrigger,
|
||||
toFileResult,
|
||||
toSlashCommandResult,
|
||||
type AutocompleteInputProps,
|
||||
type AutocompleteInputHandle,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteTrigger,
|
||||
type AutocompletePickerState,
|
||||
type PickerSelectProps,
|
||||
type FileResult,
|
||||
type SlashCommandResult as AutocompleteSlashCommandResult,
|
||||
} 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 "./utils/theme.js"
|
||||
|
||||
// Types
|
||||
export type { TUIMessage, PendingAsk, SayType, AskType, AppProps, MessageRole, View } from "./types.js"
|
||||
161
apps/cli/src/ui/store.ts
Normal file
161
apps/cli/src/ui/store.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { create } from "zustand"
|
||||
|
||||
import type { TokenUsage, ProviderSettings, TodoItem } from "@roo-code/types"
|
||||
|
||||
import type { TUIMessage, PendingAsk, FileSearchResult, SlashCommandResult, ModeResult } from "./types.js"
|
||||
|
||||
/**
|
||||
* RouterModels type for context window lookup.
|
||||
* Simplified version - we only need contextWindow from ModelInfo.
|
||||
*/
|
||||
export type RouterModels = Record<string, Record<string, { contextWindow?: number }>>
|
||||
|
||||
/**
|
||||
* CLI application state.
|
||||
*
|
||||
* Note: Autocomplete picker UI state (isOpen, selectedIndex) is now managed
|
||||
* by the useAutocompletePicker hook. The store only holds data that needs
|
||||
* to be shared between components or persisted (like search results from API).
|
||||
*/
|
||||
interface CLIState {
|
||||
// Message history
|
||||
messages: TUIMessage[]
|
||||
pendingAsk: PendingAsk | null
|
||||
|
||||
// Task state
|
||||
isLoading: boolean
|
||||
isComplete: boolean
|
||||
hasStartedTask: boolean
|
||||
error: string | null
|
||||
|
||||
// Autocomplete data (from API/extension)
|
||||
fileSearchResults: FileSearchResult[]
|
||||
allSlashCommands: SlashCommandResult[]
|
||||
availableModes: ModeResult[]
|
||||
|
||||
// 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
|
||||
|
||||
// Autocomplete data actions
|
||||
setFileSearchResults: (results: FileSearchResult[]) => void
|
||||
setAllSlashCommands: (commands: SlashCommandResult[]) => void
|
||||
setAvailableModes: (modes: ModeResult[]) => 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,
|
||||
fileSearchResults: [],
|
||||
allSlashCommands: [],
|
||||
availableModes: [],
|
||||
currentMode: null,
|
||||
tokenUsage: null,
|
||||
routerModels: null,
|
||||
apiConfiguration: null,
|
||||
currentTodos: [],
|
||||
previousTodos: [],
|
||||
}
|
||||
|
||||
export const useCLIStore = create<CLIState & CLIActions>((set) => ({
|
||||
...initialState,
|
||||
|
||||
addMessage: (msg) =>
|
||||
set((state) => {
|
||||
// Check if message already exists (by ID).
|
||||
const existingIndex = state.messages.findIndex((m) => m.id === msg.id)
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing message in place.
|
||||
const updated = [...state.messages]
|
||||
updated[existingIndex] = msg
|
||||
return { messages: updated }
|
||||
}
|
||||
|
||||
// Add new message.
|
||||
return { messages: [...state.messages, msg] }
|
||||
}),
|
||||
|
||||
updateMessage: (id, content, partial) =>
|
||||
set((state) => {
|
||||
const index = state.messages.findIndex((m) => m.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
return state
|
||||
}
|
||||
|
||||
const existing = state.messages[index]
|
||||
|
||||
if (!existing) {
|
||||
return state
|
||||
}
|
||||
|
||||
const updated = [...state.messages]
|
||||
|
||||
updated[index] = {
|
||||
...existing,
|
||||
content,
|
||||
partial: partial !== undefined ? partial : existing.partial,
|
||||
}
|
||||
|
||||
return { messages: updated }
|
||||
}),
|
||||
|
||||
setPendingAsk: (ask) => set({ pendingAsk: ask }),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
setComplete: (complete) => set({ isComplete: complete }),
|
||||
setHasStartedTask: (started) => set({ hasStartedTask: started }),
|
||||
setError: (error) => set({ error }),
|
||||
reset: () => set(initialState),
|
||||
setFileSearchResults: (results) => set({ fileSearchResults: results }),
|
||||
setAllSlashCommands: (commands) => set({ allSlashCommands: commands }),
|
||||
setAvailableModes: (modes) => set({ availableModes: modes }),
|
||||
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,
|
||||
})),
|
||||
}))
|
||||
102
apps/cli/src/ui/types.ts
Normal file
102
apps/cli/src/ui/types.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { ClineAsk, ClineSay, TodoItem } from "@roo-code/types"
|
||||
|
||||
import type { GlobalCommandAction } from "../globalCommands.js"
|
||||
|
||||
// Re-export TodoItem for convenience
|
||||
export type { TodoItem }
|
||||
|
||||
export type MessageRole = "system" | "user" | "assistant" | "tool" | "thinking"
|
||||
|
||||
export type AskType = Extract<
|
||||
ClineAsk,
|
||||
| "followup"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "tool"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
| "api_req_failed"
|
||||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
| "completion_result"
|
||||
>
|
||||
|
||||
export type SayType =
|
||||
| Extract<
|
||||
ClineSay,
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "error"
|
||||
| "api_req_started"
|
||||
| "user_feedback"
|
||||
| "checkpoint_saved"
|
||||
>
|
||||
| "thinking"
|
||||
| "tool"
|
||||
|
||||
export interface TUIMessage {
|
||||
id: string
|
||||
role: MessageRole
|
||||
content: string
|
||||
toolName?: string
|
||||
toolDisplayName?: string
|
||||
toolDisplayOutput?: string
|
||||
hasPendingToolCalls?: boolean
|
||||
partial?: boolean
|
||||
originalType?: SayType | AskType
|
||||
/** TODO items for update_todo_list tool messages */
|
||||
todos?: TodoItem[]
|
||||
/** Previous TODO items for diff display */
|
||||
previousTodos?: TodoItem[]
|
||||
}
|
||||
|
||||
export interface PendingAsk {
|
||||
id: string
|
||||
type: AskType
|
||||
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
|
||||
/** Run in ephemeral mode - no state persists after this session */
|
||||
ephemeral?: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
export type View = "UserInput" | "AgentResponse" | "ToolUse" | "Default"
|
||||
|
||||
export interface FileSearchResult {
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface SlashCommandResult {
|
||||
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 ModeResult {
|
||||
slug: string
|
||||
name: string
|
||||
description?: string
|
||||
icon?: string
|
||||
}
|
||||
79
apps/cli/src/ui/utils/theme.ts
Normal file
79
apps/cli/src/ui/utils/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
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
"extends": "@roo-code/config-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
|||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
// Bundle workspace packages that export TypeScript
|
||||
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
|
||||
noExternal: ["@roo-code/core", "@roo-code/core/message-utils", "@roo-code/types", "@roo-code/vscode-shim"],
|
||||
external: [
|
||||
// Keep native modules external
|
||||
"@anthropic-ai/sdk",
|
||||
|
|
@ -20,5 +20,12 @@ export default defineConfig({
|
|||
"@anthropic-ai/vertex-sdk",
|
||||
// Keep @vscode/ripgrep external - we bundle the binary separately
|
||||
"@vscode/ripgrep",
|
||||
// Optional dev dependency of ink - not needed at runtime
|
||||
"react-devtools-core",
|
||||
],
|
||||
esbuildOptions(options) {
|
||||
// Enable JSX for React/Ink components
|
||||
options.jsx = "automatic"
|
||||
options.jsxImportSource = "react"
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ export default defineConfig({
|
|||
environment: "node",
|
||||
watch: false,
|
||||
testTimeout: 120_000, // 2m for integration tests.
|
||||
include: ["src/**/*.test.ts"],
|
||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
"@vscode/test-electron": "^2.4.0",
|
||||
"glob": "^11.1.0",
|
||||
"mocha": "^11.1.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "5.8.3"
|
||||
"rimraf": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
"rimraf": "^6.0.1",
|
||||
"tsx": "^4.19.3",
|
||||
"turbo": "^2.5.6",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
|
|
@ -63,7 +63,9 @@
|
|||
"brace-expansion": "^2.0.2",
|
||||
"form-data": ">=4.0.4",
|
||||
"bluebird": ">=3.7.2",
|
||||
"glob": ">=11.1.0"
|
||||
"glob": ">=11.1.0",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
"description": "Platform agnostic core functionality for Roo Code.",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./message-utils": "./src/message-utils/index.ts",
|
||||
"./debug-log": "./src/debug-log/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext=ts --max-warnings=0",
|
||||
"check-types": "tsc --noEmit",
|
||||
|
|
|
|||
91
packages/core/src/debug-log/index.ts
Normal file
91
packages/core/src/debug-log/index.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* File-based debug logging utility
|
||||
*
|
||||
* This writes logs to ~/.roo/cli-debug.log, avoiding stdout/stderr
|
||||
* which would break TUI applications. The log format is timestamped JSON.
|
||||
*
|
||||
* Usage:
|
||||
* import { debugLog, DebugLogger } from "@roo-code/core/debug-log"
|
||||
*
|
||||
* // Simple logging
|
||||
* debugLog("handleModeSwitch", { mode: newMode, configId })
|
||||
*
|
||||
* // Or create a named logger for a component
|
||||
* const log = new DebugLogger("ClineProvider")
|
||||
* log.info("handleModeSwitch", { mode: newMode })
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
const DEBUG_LOG_PATH = path.join(os.homedir(), ".roo", "cli-debug.log")
|
||||
|
||||
/**
|
||||
* Simple file-based debug log function.
|
||||
* Writes timestamped entries to ~/.roo/cli-debug.log
|
||||
*/
|
||||
export function debugLog(message: string, data?: unknown): void {
|
||||
try {
|
||||
const logDir = path.dirname(DEBUG_LOG_PATH)
|
||||
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
const entry = data
|
||||
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
|
||||
: `[${timestamp}] ${message}\n`
|
||||
|
||||
fs.appendFileSync(DEBUG_LOG_PATH, entry)
|
||||
} catch {
|
||||
// NO-OP - don't let logging errors break functionality
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logger with component context.
|
||||
* Prefixes all messages with the component name.
|
||||
*/
|
||||
export class DebugLogger {
|
||||
private component: string
|
||||
|
||||
constructor(component: string) {
|
||||
this.component = component
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message with optional data
|
||||
*/
|
||||
debug(message: string, data?: unknown): void {
|
||||
debugLog(`[${this.component}] ${message}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for debug
|
||||
*/
|
||||
info(message: string, data?: unknown): void {
|
||||
this.debug(message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning
|
||||
*/
|
||||
warn(message: string, data?: unknown): void {
|
||||
debugLog(`[${this.component}] WARN: ${message}`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error
|
||||
*/
|
||||
error(message: string, data?: unknown): void {
|
||||
debugLog(`[${this.component}] ERROR: ${message}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured logger for provider/mode debugging
|
||||
*/
|
||||
export const providerDebugLog = new DebugLogger("ProviderSettings")
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from "./custom-tools/index.js"
|
||||
export * from "./message-utils/index.js"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
// npx vitest run packages/core/src/message-utils/__tests__/consolidateApiRequests.spec.ts
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { consolidateApiRequests } from "../consolidateApiRequests.js"
|
||||
|
||||
describe("consolidateApiRequests", () => {
|
||||
// Helper function to create a basic api_req_started message
|
||||
const createApiReqStarted = (ts: number, data: Record<string, unknown> = {}): ClineMessage => ({
|
||||
ts,
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(data),
|
||||
})
|
||||
|
||||
// Helper function to create a basic api_req_finished message
|
||||
const createApiReqFinished = (ts: number, data: Record<string, unknown> = {}): ClineMessage => ({
|
||||
ts,
|
||||
type: "say",
|
||||
say: "api_req_finished",
|
||||
text: JSON.stringify(data),
|
||||
})
|
||||
|
||||
// Helper function to create a regular text message
|
||||
const createTextMessage = (ts: number, text: string): ClineMessage => ({
|
||||
ts,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text,
|
||||
})
|
||||
|
||||
it("should consolidate a matching pair of api_req_started and api_req_finished messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStarted(1000, { request: "GET /api/data" }),
|
||||
createApiReqFinished(1001, { cost: 0.005 }),
|
||||
]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.say).toBe("api_req_started")
|
||||
|
||||
const parsedText = JSON.parse(result[0]!.text || "{}")
|
||||
expect(parsedText.request).toBe("GET /api/data")
|
||||
expect(parsedText.cost).toBe(0.005)
|
||||
})
|
||||
|
||||
it("should handle messages with no api_req pairs", () => {
|
||||
const messages: ClineMessage[] = [createTextMessage(1000, "Hello"), createTextMessage(1001, "World")]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
const result = consolidateApiRequests([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle single message array", () => {
|
||||
const messages: ClineMessage[] = [createTextMessage(1000, "Hello")]
|
||||
const result = consolidateApiRequests(messages)
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
it("should preserve non-api messages in the result", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createTextMessage(1000, "Before"),
|
||||
createApiReqStarted(1001, { request: "test" }),
|
||||
createApiReqFinished(1002, { cost: 0.01 }),
|
||||
createTextMessage(1003, "After"),
|
||||
]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
expect(result.length).toBe(3)
|
||||
expect(result[0]!.text).toBe("Before")
|
||||
expect(result[1]!.say).toBe("api_req_started")
|
||||
expect(result[2]!.text).toBe("After")
|
||||
})
|
||||
|
||||
it("should handle multiple api_req pairs", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStarted(1000, { request: "first" }),
|
||||
createApiReqFinished(1001, { cost: 0.01 }),
|
||||
createApiReqStarted(1002, { request: "second" }),
|
||||
createApiReqFinished(1003, { cost: 0.02 }),
|
||||
]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(JSON.parse(result[0]!.text || "{}").request).toBe("first")
|
||||
expect(JSON.parse(result[1]!.text || "{}").request).toBe("second")
|
||||
})
|
||||
|
||||
it("should handle orphan api_req_started without finish", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqStarted(1000, { request: "orphan" }),
|
||||
createTextMessage(1001, "Text"),
|
||||
]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]!.say).toBe("api_req_started")
|
||||
expect(JSON.parse(result[0]!.text || "{}").request).toBe("orphan")
|
||||
})
|
||||
|
||||
it("should handle invalid JSON in message text", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ ts: 1000, type: "say", say: "api_req_started", text: "invalid json" },
|
||||
createApiReqFinished(1001, { cost: 0.01 }),
|
||||
]
|
||||
|
||||
const result = consolidateApiRequests(messages)
|
||||
|
||||
// Should still consolidate, merging what it can
|
||||
expect(result.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
// npx vitest run packages/core/src/message-utils/__tests__/consolidateCommands.spec.ts
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { consolidateCommands, COMMAND_OUTPUT_STRING } from "../consolidateCommands.js"
|
||||
|
||||
describe("consolidateCommands", () => {
|
||||
describe("command sequences", () => {
|
||||
it("should consolidate command and command_output messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
|
||||
{ type: "ask", ask: "command_output", text: "file1.txt", ts: 1001 },
|
||||
{ type: "ask", ask: "command_output", text: "file2.txt", ts: 1002 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.ask).toBe("command")
|
||||
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}file1.txt\nfile2.txt`)
|
||||
})
|
||||
|
||||
it("should handle multiple command sequences", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
|
||||
{ type: "ask", ask: "command_output", text: "output1", ts: 1001 },
|
||||
{ type: "ask", ask: "command", text: "pwd", ts: 1002 },
|
||||
{ type: "ask", ask: "command_output", text: "output2", ts: 1003 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}output1`)
|
||||
expect(result[1]!.text).toBe(`pwd\n${COMMAND_OUTPUT_STRING}output2`)
|
||||
})
|
||||
|
||||
it("should handle command without output", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
|
||||
{ type: "say", say: "text", text: "some text", ts: 1001 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result[0]!.ask).toBe("command")
|
||||
expect(result[0]!.text).toBe("ls")
|
||||
expect(result[1]!.say).toBe("text")
|
||||
})
|
||||
|
||||
it("should handle duplicate outputs (ask and say with same text)", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ type: "ask", ask: "command", text: "ls", ts: 1000 },
|
||||
{ type: "ask", ask: "command_output", text: "same output", ts: 1001 },
|
||||
{ type: "say", say: "command_output", text: "same output", ts: 1002 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}same output`)
|
||||
})
|
||||
})
|
||||
|
||||
describe("MCP server sequences", () => {
|
||||
it("should consolidate use_mcp_server and mcp_server_response messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "use_mcp_server",
|
||||
text: JSON.stringify({ server: "test", tool: "myTool" }),
|
||||
ts: 1000,
|
||||
},
|
||||
{ type: "say", say: "mcp_server_response", text: "response data", ts: 1001 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.ask).toBe("use_mcp_server")
|
||||
const parsed = JSON.parse(result[0]!.text || "{}")
|
||||
expect(parsed.server).toBe("test")
|
||||
expect(parsed.response).toBe("response data")
|
||||
})
|
||||
|
||||
it("should handle MCP request without response", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "use_mcp_server",
|
||||
text: JSON.stringify({ server: "test" }),
|
||||
ts: 1000,
|
||||
},
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0]!.ask).toBe("use_mcp_server")
|
||||
})
|
||||
|
||||
it("should handle multiple MCP responses", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{
|
||||
type: "ask",
|
||||
ask: "use_mcp_server",
|
||||
text: JSON.stringify({ server: "test" }),
|
||||
ts: 1000,
|
||||
},
|
||||
{ type: "say", say: "mcp_server_response", text: "response1", ts: 1001 },
|
||||
{ type: "say", say: "mcp_server_response", text: "response2", ts: 1002 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(1)
|
||||
const parsed = JSON.parse(result[0]!.text || "{}")
|
||||
expect(parsed.response).toBe("response1\nresponse2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mixed messages", () => {
|
||||
it("should preserve non-command, non-MCP messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ type: "say", say: "text", text: "before", ts: 1000 },
|
||||
{ type: "ask", ask: "command", text: "ls", ts: 1001 },
|
||||
{ type: "ask", ask: "command_output", text: "output", ts: 1002 },
|
||||
{ type: "say", say: "text", text: "after", ts: 1003 },
|
||||
]
|
||||
|
||||
const result = consolidateCommands(messages)
|
||||
|
||||
expect(result.length).toBe(3)
|
||||
expect(result[0]!.text).toBe("before")
|
||||
expect(result[1]!.text).toBe(`ls\n${COMMAND_OUTPUT_STRING}output`)
|
||||
expect(result[2]!.text).toBe("after")
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
const result = consolidateCommands([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
// npx vitest run packages/core/src/message-utils/__tests__/consolidateTokenUsage.spec.ts
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { consolidateTokenUsage, hasTokenUsageChanged, hasToolUsageChanged } from "../consolidateTokenUsage.js"
|
||||
|
||||
describe("consolidateTokenUsage", () => {
|
||||
// Helper function to create a basic api_req_started message
|
||||
const createApiReqMessage = (
|
||||
ts: number,
|
||||
data: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
cost?: number
|
||||
},
|
||||
): ClineMessage => ({
|
||||
ts,
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify(data),
|
||||
})
|
||||
|
||||
describe("basic token accumulation", () => {
|
||||
it("should accumulate tokens from a single message", () => {
|
||||
const messages: ClineMessage[] = [createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cost: 0.01 })]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(100)
|
||||
expect(result.totalTokensOut).toBe(50)
|
||||
expect(result.totalCost).toBe(0.01)
|
||||
})
|
||||
|
||||
it("should accumulate tokens from multiple messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cost: 0.01 }),
|
||||
createApiReqMessage(1001, { tokensIn: 200, tokensOut: 100, cost: 0.02 }),
|
||||
]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(300)
|
||||
expect(result.totalTokensOut).toBe(150)
|
||||
expect(result.totalCost).toBeCloseTo(0.03)
|
||||
})
|
||||
|
||||
it("should handle cache writes and reads", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50, cacheWrites: 500, cacheReads: 200 }),
|
||||
]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.totalCacheWrites).toBe(500)
|
||||
expect(result.totalCacheReads).toBe(200)
|
||||
})
|
||||
|
||||
it("should handle empty messages array", () => {
|
||||
const result = consolidateTokenUsage([])
|
||||
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCost).toBe(0)
|
||||
expect(result.contextTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("context tokens calculation", () => {
|
||||
it("should calculate context tokens from the last API request", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50 }),
|
||||
createApiReqMessage(1001, { tokensIn: 200, tokensOut: 100 }),
|
||||
]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
// Context tokens = tokensIn + tokensOut from last message
|
||||
expect(result.contextTokens).toBe(300) // 200 + 100
|
||||
})
|
||||
|
||||
it("should handle condense_context messages for context tokens", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
createApiReqMessage(1000, { tokensIn: 100, tokensOut: 50 }),
|
||||
{
|
||||
ts: 1001,
|
||||
type: "say",
|
||||
say: "condense_context",
|
||||
contextCondense: { newContextTokens: 5000, cost: 0.05 },
|
||||
} as ClineMessage,
|
||||
]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.contextTokens).toBe(5000)
|
||||
expect(result.totalCost).toBeCloseTo(0.05)
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid data handling", () => {
|
||||
it("should handle messages with invalid JSON", () => {
|
||||
const messages: ClineMessage[] = [{ ts: 1000, type: "say", say: "api_req_started", text: "invalid json" }]
|
||||
|
||||
// Should not throw
|
||||
const result = consolidateTokenUsage(messages)
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
})
|
||||
|
||||
it("should skip non-api_req_started messages", () => {
|
||||
const messages: ClineMessage[] = [
|
||||
{ ts: 1000, type: "say", say: "text", text: "hello" },
|
||||
createApiReqMessage(1001, { tokensIn: 100, tokensOut: 50 }),
|
||||
]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(100)
|
||||
expect(result.totalTokensOut).toBe(50)
|
||||
})
|
||||
|
||||
it("should handle missing token values", () => {
|
||||
const messages: ClineMessage[] = [createApiReqMessage(1000, { cost: 0.01 })]
|
||||
|
||||
const result = consolidateTokenUsage(messages)
|
||||
|
||||
expect(result.totalTokensIn).toBe(0)
|
||||
expect(result.totalTokensOut).toBe(0)
|
||||
expect(result.totalCost).toBe(0.01)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasTokenUsageChanged", () => {
|
||||
it("should return true when snapshot is undefined", () => {
|
||||
const current = {
|
||||
totalTokensIn: 100,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
contextTokens: 150,
|
||||
}
|
||||
|
||||
expect(hasTokenUsageChanged(current, undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when values are the same", () => {
|
||||
const current = {
|
||||
totalTokensIn: 100,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
contextTokens: 150,
|
||||
}
|
||||
const snapshot = { ...current }
|
||||
|
||||
expect(hasTokenUsageChanged(current, snapshot)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when totalTokensIn changes", () => {
|
||||
const current = {
|
||||
totalTokensIn: 200,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
contextTokens: 150,
|
||||
}
|
||||
const snapshot = {
|
||||
totalTokensIn: 100,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
contextTokens: 150,
|
||||
}
|
||||
|
||||
expect(hasTokenUsageChanged(current, snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when totalCost changes", () => {
|
||||
const current = {
|
||||
totalTokensIn: 100,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.02,
|
||||
contextTokens: 150,
|
||||
}
|
||||
const snapshot = {
|
||||
totalTokensIn: 100,
|
||||
totalTokensOut: 50,
|
||||
totalCost: 0.01,
|
||||
contextTokens: 150,
|
||||
}
|
||||
|
||||
expect(hasTokenUsageChanged(current, snapshot)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasToolUsageChanged", () => {
|
||||
it("should return true when snapshot is undefined", () => {
|
||||
const current = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
|
||||
expect(hasToolUsageChanged(current, undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when values are the same", () => {
|
||||
const current = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
const snapshot = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
|
||||
expect(hasToolUsageChanged(current, snapshot)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when a tool is added", () => {
|
||||
const current = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
write_to_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
const snapshot = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
|
||||
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when attempts change", () => {
|
||||
const current = {
|
||||
read_file: { attempts: 2, failures: 0 },
|
||||
}
|
||||
const snapshot = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
|
||||
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when failures change", () => {
|
||||
const current = {
|
||||
read_file: { attempts: 1, failures: 1 },
|
||||
}
|
||||
const snapshot = {
|
||||
read_file: { attempts: 1, failures: 0 },
|
||||
}
|
||||
|
||||
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
|
||||
})
|
||||
})
|
||||
90
packages/core/src/message-utils/consolidateApiRequests.ts
Normal file
90
packages/core/src/message-utils/consolidateApiRequests.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Consolidates API request start and finish messages in an array of ClineMessages.
|
||||
*
|
||||
* This function looks for pairs of 'api_req_started' and 'api_req_finished' messages.
|
||||
* When it finds a pair, it consolidates them into a single message.
|
||||
* The JSON data in the text fields of both messages are merged.
|
||||
*
|
||||
* @param messages - An array of ClineMessage objects to process.
|
||||
* @returns A new array of ClineMessage objects with API requests consolidated.
|
||||
*
|
||||
* @example
|
||||
* const messages = [
|
||||
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data"}', ts: 1000 },
|
||||
* { type: "say", say: "api_req_finished", text: '{"cost":0.005}', ts: 1001 }
|
||||
* ];
|
||||
* const result = consolidateApiRequests(messages);
|
||||
* // Result: [{ type: "say", say: "api_req_started", text: '{"request":"GET /api/data","cost":0.005}', ts: 1000 }]
|
||||
*/
|
||||
export function consolidateApiRequests(messages: ClineMessage[]): ClineMessage[] {
|
||||
if (messages.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (messages.length === 1) {
|
||||
return messages
|
||||
}
|
||||
|
||||
let isMergeNecessary = false
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === "say" && (msg.say === "api_req_started" || msg.say === "api_req_finished")) {
|
||||
isMergeNecessary = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMergeNecessary) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const result: ClineMessage[] = []
|
||||
const startedIndices: number[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== "say" || (message.say !== "api_req_started" && message.say !== "api_req_finished")) {
|
||||
result.push(message)
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.say === "api_req_started") {
|
||||
// Add to result and track the index.
|
||||
result.push(message)
|
||||
startedIndices.push(result.length - 1)
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the most recent api_req_started that hasn't been consolidated.
|
||||
const startIndex = startedIndices.length > 0 ? startedIndices.pop() : undefined
|
||||
|
||||
if (startIndex !== undefined) {
|
||||
const startMessage = result[startIndex]
|
||||
if (!startMessage) continue
|
||||
|
||||
let startData = {}
|
||||
let finishData = {}
|
||||
|
||||
try {
|
||||
if (startMessage.text) {
|
||||
startData = JSON.parse(startMessage.text)
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
try {
|
||||
if (message.text) {
|
||||
finishData = JSON.parse(message.text)
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
|
||||
result[startIndex] = { ...startMessage, text: JSON.stringify({ ...startData, ...finishData }) }
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
160
packages/core/src/message-utils/consolidateCommands.ts
Normal file
160
packages/core/src/message-utils/consolidateCommands.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
import { safeJsonParse } from "./safeJsonParse.js"
|
||||
|
||||
export const COMMAND_OUTPUT_STRING = "Output:"
|
||||
|
||||
/**
|
||||
* Consolidates sequences of command and command_output messages in an array of ClineMessages.
|
||||
* Also consolidates sequences of use_mcp_server and mcp_server_response messages.
|
||||
*
|
||||
* This function processes an array of ClineMessages objects, looking for sequences
|
||||
* where a 'command' message is followed by one or more 'command_output' messages,
|
||||
* or where a 'use_mcp_server' message is followed by one or more 'mcp_server_response' messages.
|
||||
* When such a sequence is found, it consolidates them into a single message, merging
|
||||
* their text contents.
|
||||
*
|
||||
* @param messages - An array of ClineMessage objects to process.
|
||||
* @returns A new array of ClineMessage objects with command and MCP sequences consolidated.
|
||||
*
|
||||
* @example
|
||||
* const messages: ClineMessage[] = [
|
||||
* { type: 'ask', ask: 'command', text: 'ls', ts: 1625097600000 },
|
||||
* { type: 'ask', ask: 'command_output', text: 'file1.txt', ts: 1625097601000 },
|
||||
* { type: 'ask', ask: 'command_output', text: 'file2.txt', ts: 1625097602000 }
|
||||
* ];
|
||||
* const result = consolidateCommands(messages);
|
||||
* // Result: [{ type: 'ask', ask: 'command', text: 'ls\nfile1.txt\nfile2.txt', ts: 1625097600000 }]
|
||||
*/
|
||||
export function consolidateCommands(messages: ClineMessage[]): ClineMessage[] {
|
||||
const consolidatedMessages = new Map<number, ClineMessage>()
|
||||
const processedIndices = new Set<number>()
|
||||
|
||||
// Single pass through all messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (!msg) continue
|
||||
|
||||
// Handle MCP server requests
|
||||
if (msg.type === "ask" && msg.ask === "use_mcp_server") {
|
||||
// Look ahead for MCP responses
|
||||
const responses: string[] = []
|
||||
let j = i + 1
|
||||
|
||||
while (j < messages.length) {
|
||||
const nextMsg = messages[j]
|
||||
if (!nextMsg) {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
if (nextMsg.say === "mcp_server_response") {
|
||||
responses.push(nextMsg.text || "")
|
||||
processedIndices.add(j)
|
||||
j++
|
||||
} else if (nextMsg.type === "ask" && nextMsg.ask === "use_mcp_server") {
|
||||
// Stop if we encounter another MCP request
|
||||
break
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.length > 0) {
|
||||
// Parse the JSON from the message text
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const jsonObj = safeJsonParse<any>(msg.text || "{}", {})
|
||||
|
||||
// Add the response to the JSON object
|
||||
jsonObj.response = responses.join("\n")
|
||||
|
||||
// Stringify the updated JSON object
|
||||
const consolidatedText = JSON.stringify(jsonObj)
|
||||
|
||||
consolidatedMessages.set(msg.ts, { ...msg, text: consolidatedText })
|
||||
} else {
|
||||
// If there's no response, just keep the original message
|
||||
consolidatedMessages.set(msg.ts, { ...msg })
|
||||
}
|
||||
}
|
||||
// Handle command sequences
|
||||
else if (msg.type === "ask" && msg.ask === "command") {
|
||||
let consolidatedText = msg.text || ""
|
||||
let j = i + 1
|
||||
let previous: { type: "ask" | "say"; text: string } | undefined
|
||||
let lastProcessedIndex = i
|
||||
|
||||
while (j < messages.length) {
|
||||
const currentMsg = messages[j]
|
||||
if (!currentMsg) {
|
||||
j++
|
||||
continue
|
||||
}
|
||||
const { type, ask, say, text = "" } = currentMsg
|
||||
|
||||
if (type === "ask" && ask === "command") {
|
||||
break // Stop if we encounter the next command.
|
||||
}
|
||||
|
||||
if (ask === "command_output" || say === "command_output") {
|
||||
if (!previous) {
|
||||
consolidatedText += `\n${COMMAND_OUTPUT_STRING}`
|
||||
}
|
||||
|
||||
const isDuplicate = previous && previous.type !== type && previous.text === text
|
||||
|
||||
if (text.length > 0 && !isDuplicate) {
|
||||
// Add a newline before adding the text if there's already content
|
||||
if (
|
||||
previous &&
|
||||
consolidatedText.length >
|
||||
consolidatedText.indexOf(COMMAND_OUTPUT_STRING) + COMMAND_OUTPUT_STRING.length
|
||||
) {
|
||||
consolidatedText += "\n"
|
||||
}
|
||||
consolidatedText += text
|
||||
}
|
||||
|
||||
previous = { type, text }
|
||||
processedIndices.add(j)
|
||||
lastProcessedIndex = j
|
||||
}
|
||||
|
||||
j++
|
||||
}
|
||||
|
||||
consolidatedMessages.set(msg.ts, { ...msg, text: consolidatedText })
|
||||
|
||||
// Only skip ahead if we actually processed command outputs
|
||||
if (lastProcessedIndex > i) {
|
||||
i = lastProcessedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final result: filter out processed messages and use consolidated versions
|
||||
const result: ClineMessage[] = []
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (!msg) continue
|
||||
|
||||
// Skip messages that were processed as outputs/responses
|
||||
if (processedIndices.has(i)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip command_output and mcp_server_response messages
|
||||
if (msg.ask === "command_output" || msg.say === "command_output" || msg.say === "mcp_server_response") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use consolidated version if available
|
||||
const consolidatedMsg = consolidatedMessages.get(msg.ts)
|
||||
if (consolidatedMsg) {
|
||||
result.push(consolidatedMsg)
|
||||
} else {
|
||||
result.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
157
packages/core/src/message-utils/consolidateTokenUsage.ts
Normal file
157
packages/core/src/message-utils/consolidateTokenUsage.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import type { TokenUsage, ToolUsage, ToolName, ClineMessage } from "@roo-code/types"
|
||||
|
||||
export type ParsedApiReqStartedTextType = {
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
cacheWrites: number
|
||||
cacheReads: number
|
||||
cost?: number // Only present if consolidateApiRequests has been called
|
||||
apiProtocol?: "anthropic" | "openai"
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates token usage metrics from an array of ClineMessages.
|
||||
*
|
||||
* This function processes 'condense_context' messages and 'api_req_started' messages that have been
|
||||
* consolidated with their corresponding 'api_req_finished' messages by the consolidateApiRequests function.
|
||||
* It extracts and sums up the tokensIn, tokensOut, cacheWrites, cacheReads, and cost from these messages.
|
||||
*
|
||||
* @param messages - An array of ClineMessage objects to process.
|
||||
* @returns A TokenUsage object containing totalTokensIn, totalTokensOut, totalCacheWrites, totalCacheReads, totalCost, and contextTokens.
|
||||
*
|
||||
* @example
|
||||
* const messages = [
|
||||
* { type: "say", say: "api_req_started", text: '{"request":"GET /api/data","tokensIn":10,"tokensOut":20,"cost":0.005}', ts: 1000 }
|
||||
* ];
|
||||
* const { totalTokensIn, totalTokensOut, totalCost } = consolidateTokenUsage(messages);
|
||||
* // Result: { totalTokensIn: 10, totalTokensOut: 20, totalCost: 0.005 }
|
||||
*/
|
||||
export function consolidateTokenUsage(messages: ClineMessage[]): TokenUsage {
|
||||
const result: TokenUsage = {
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
totalCacheWrites: undefined,
|
||||
totalCacheReads: undefined,
|
||||
totalCost: 0,
|
||||
contextTokens: 0,
|
||||
}
|
||||
|
||||
// Calculate running totals.
|
||||
messages.forEach((message) => {
|
||||
if (message.type === "say" && message.say === "api_req_started" && message.text) {
|
||||
try {
|
||||
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedText
|
||||
|
||||
if (typeof tokensIn === "number") {
|
||||
result.totalTokensIn += tokensIn
|
||||
}
|
||||
|
||||
if (typeof tokensOut === "number") {
|
||||
result.totalTokensOut += tokensOut
|
||||
}
|
||||
|
||||
if (typeof cacheWrites === "number") {
|
||||
result.totalCacheWrites = (result.totalCacheWrites ?? 0) + cacheWrites
|
||||
}
|
||||
|
||||
if (typeof cacheReads === "number") {
|
||||
result.totalCacheReads = (result.totalCacheReads ?? 0) + cacheReads
|
||||
}
|
||||
|
||||
if (typeof cost === "number") {
|
||||
result.totalCost += cost
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error)
|
||||
}
|
||||
} else if (message.type === "say" && message.say === "condense_context") {
|
||||
result.totalCost += message.contextCondense?.cost ?? 0
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate context tokens, from the last API request started or condense
|
||||
// context message.
|
||||
result.contextTokens = 0
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (!message) continue
|
||||
|
||||
if (message.type === "say" && message.say === "api_req_started" && message.text) {
|
||||
try {
|
||||
const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text)
|
||||
const { tokensIn, tokensOut } = parsedText
|
||||
|
||||
// Since tokensIn now stores TOTAL input tokens (including cache tokens),
|
||||
// we no longer need to add cacheWrites and cacheReads separately.
|
||||
// This applies to both Anthropic and OpenAI protocols.
|
||||
result.contextTokens = (tokensIn || 0) + (tokensOut || 0)
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
continue
|
||||
}
|
||||
} else if (message.type === "say" && message.say === "condense_context") {
|
||||
result.contextTokens = message.contextCondense?.newContextTokens ?? 0
|
||||
}
|
||||
if (result.contextTokens) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token usage has changed by comparing relevant properties.
|
||||
* @param current - Current token usage data
|
||||
* @param snapshot - Previous snapshot to compare against
|
||||
* @returns true if any relevant property has changed or snapshot is undefined
|
||||
*/
|
||||
export function hasTokenUsageChanged(current: TokenUsage, snapshot?: TokenUsage): boolean {
|
||||
if (!snapshot) {
|
||||
return true
|
||||
}
|
||||
|
||||
const keysToCompare: (keyof TokenUsage)[] = [
|
||||
"totalTokensIn",
|
||||
"totalTokensOut",
|
||||
"totalCacheWrites",
|
||||
"totalCacheReads",
|
||||
"totalCost",
|
||||
"contextTokens",
|
||||
]
|
||||
|
||||
return keysToCompare.some((key) => current[key] !== snapshot[key])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool usage has changed by comparing attempts and failures.
|
||||
* @param current - Current tool usage data
|
||||
* @param snapshot - Previous snapshot to compare against (undefined treated as empty)
|
||||
* @returns true if any tool's attempts/failures have changed between current and snapshot
|
||||
*/
|
||||
export function hasToolUsageChanged(current: ToolUsage, snapshot?: ToolUsage): boolean {
|
||||
// Treat undefined snapshot as empty object for consistent comparison
|
||||
const effectiveSnapshot = snapshot ?? {}
|
||||
|
||||
const currentKeys = Object.keys(current) as ToolName[]
|
||||
const snapshotKeys = Object.keys(effectiveSnapshot) as ToolName[]
|
||||
|
||||
// Check if number of tools changed
|
||||
if (currentKeys.length !== snapshotKeys.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if any tool's stats changed
|
||||
return currentKeys.some((key) => {
|
||||
const currentTool = current[key]
|
||||
const snapshotTool = effectiveSnapshot[key]
|
||||
|
||||
if (!snapshotTool || !currentTool) {
|
||||
return true
|
||||
}
|
||||
|
||||
return currentTool.attempts !== snapshotTool.attempts || currentTool.failures !== snapshotTool.failures
|
||||
})
|
||||
}
|
||||
12
packages/core/src/message-utils/index.ts
Normal file
12
packages/core/src/message-utils/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export {
|
||||
type ParsedApiReqStartedTextType,
|
||||
consolidateTokenUsage,
|
||||
hasTokenUsageChanged,
|
||||
hasToolUsageChanged,
|
||||
} from "./consolidateTokenUsage.js"
|
||||
|
||||
export { consolidateApiRequests } from "./consolidateApiRequests.js"
|
||||
|
||||
export { consolidateCommands, COMMAND_OUTPUT_STRING } from "./consolidateCommands.js"
|
||||
|
||||
export { safeJsonParse } from "./safeJsonParse.js"
|
||||
20
packages/core/src/message-utils/safeJsonParse.ts
Normal file
20
packages/core/src/message-utils/safeJsonParse.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Safely parses JSON without crashing on invalid input.
|
||||
*
|
||||
* @param jsonString The string to parse
|
||||
* @param defaultValue Value to return if parsing fails
|
||||
* @returns Parsed JSON object or defaultValue if parsing fails
|
||||
*/
|
||||
export function safeJsonParse<T>(jsonString: string | null | undefined, defaultValue?: T): T | undefined {
|
||||
if (!jsonString) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonString) as T
|
||||
} catch (error) {
|
||||
// Log the error to the console for debugging.
|
||||
console.error("Error parsing JSON:", error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"@roo-code/config-typescript": "workspace:^",
|
||||
"@types/node": "^24.1.0",
|
||||
"globals": "^16.3.0",
|
||||
"tsup": "^8.3.5",
|
||||
"tsup": "^8.4.0",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
packages/types/src/git.ts
Normal file
13
packages/types/src/git.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export interface GitRepositoryInfo {
|
||||
repositoryUrl?: string
|
||||
repositoryName?: string
|
||||
defaultBranch?: string
|
||||
}
|
||||
|
||||
export interface GitCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
subject: string
|
||||
author: string
|
||||
date: string
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ export * from "./custom-tool.js"
|
|||
export * from "./events.js"
|
||||
export * from "./experiment.js"
|
||||
export * from "./followup.js"
|
||||
export * from "./git.js"
|
||||
export * from "./global-settings.js"
|
||||
export * from "./history.js"
|
||||
export * from "./image-generation.js"
|
||||
|
|
@ -24,6 +25,7 @@ export * from "./terminal.js"
|
|||
export * from "./tool.js"
|
||||
export * from "./tool-params.js"
|
||||
export * from "./type-fu.js"
|
||||
export * from "./vscode-extension-host.js"
|
||||
export * from "./vscode.js"
|
||||
|
||||
export * from "./providers/index.js"
|
||||
|
|
|
|||
|
|
@ -86,3 +86,8 @@ export const installMarketplaceItemOptionsSchema = z.object({
|
|||
})
|
||||
|
||||
export type InstallMarketplaceItemOptions = z.infer<typeof installMarketplaceItemOptionsSchema>
|
||||
|
||||
export interface MarketplaceInstalledMetadata {
|
||||
project: Record<string, { type: string }>
|
||||
global: Record<string, { type: string }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* MCP Server Use Types
|
||||
* McpServerUse
|
||||
*/
|
||||
|
||||
export interface McpServerUse {
|
||||
type: string
|
||||
serverName: string
|
||||
|
|
@ -39,3 +40,91 @@ export const mcpExecutionStatusSchema = z.discriminatedUnion("status", [
|
|||
])
|
||||
|
||||
export type McpExecutionStatus = z.infer<typeof mcpExecutionStatusSchema>
|
||||
|
||||
/**
|
||||
* McpServer
|
||||
*/
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
config: string
|
||||
status: "connected" | "connecting" | "disconnected"
|
||||
error?: string
|
||||
errorHistory?: McpErrorEntry[]
|
||||
tools?: McpTool[]
|
||||
resources?: McpResource[]
|
||||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
source?: "global" | "project"
|
||||
projectPath?: string
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema?: object
|
||||
alwaysAllow?: boolean
|
||||
enabledForPrompt?: boolean
|
||||
}
|
||||
|
||||
export type McpResource = {
|
||||
uri: string
|
||||
name: string
|
||||
mimeType?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type McpResourceTemplate = {
|
||||
uriTemplate: string
|
||||
name: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type McpResourceResponse = {
|
||||
_meta?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
contents: Array<{
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type McpToolCallResponse = {
|
||||
_meta?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
content: Array<
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "image"
|
||||
data: string
|
||||
mimeType: string
|
||||
}
|
||||
| {
|
||||
type: "audio"
|
||||
data: string
|
||||
mimeType: string
|
||||
}
|
||||
| {
|
||||
type: "resource"
|
||||
resource: {
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}
|
||||
}
|
||||
>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export type McpErrorEntry = {
|
||||
message: string
|
||||
timestamp: number
|
||||
level: "error" | "warn" | "info"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod"
|
||||
import { DynamicProvider, LocalProvider } from "./provider-settings.js"
|
||||
|
||||
/**
|
||||
* ReasoningEffort
|
||||
|
|
@ -140,3 +141,7 @@ export const modelInfoSchema = z.object({
|
|||
})
|
||||
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>
|
||||
|
||||
export type ModelRecord = Record<string, ModelInfo>
|
||||
|
||||
export type RouterModels = Record<DynamicProvider | LocalProvider, ModelRecord>
|
||||
|
|
|
|||
644
packages/types/src/vscode-extension-host.ts
Normal file
644
packages/types/src/vscode-extension-host.ts
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
import { z } from "zod"
|
||||
|
||||
import type { GlobalSettings, RooCodeSettings } from "./global-settings.js"
|
||||
import type { ProviderSettings, ProviderSettingsEntry } from "./provider-settings.js"
|
||||
import type { HistoryItem } from "./history.js"
|
||||
import type { ModeConfig, PromptComponent } from "./mode.js"
|
||||
import type { TelemetrySetting } from "./telemetry.js"
|
||||
import type { Experiments } from "./experiment.js"
|
||||
import type { ClineMessage, QueuedMessage } from "./message.js"
|
||||
import {
|
||||
type MarketplaceItem,
|
||||
type MarketplaceInstalledMetadata,
|
||||
type InstallMarketplaceItemOptions,
|
||||
marketplaceItemSchema,
|
||||
} from "./marketplace.js"
|
||||
import type { TodoItem } from "./todo.js"
|
||||
import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, ShareVisibility } from "./cloud.js"
|
||||
import type { SerializedCustomToolDefinition } from "./custom-tool.js"
|
||||
import type { GitCommit } from "./git.js"
|
||||
import type { McpServer } from "./mcp.js"
|
||||
import type { ModelRecord, RouterModels } from "./model.js"
|
||||
|
||||
/**
|
||||
* ExtensionMessage
|
||||
* Extension -> Webview | CLI
|
||||
*/
|
||||
export interface ExtensionMessage {
|
||||
type:
|
||||
| "action"
|
||||
| "state"
|
||||
| "selectedImages"
|
||||
| "theme"
|
||||
| "workspaceUpdated"
|
||||
| "invoke"
|
||||
| "messageUpdated"
|
||||
| "mcpServers"
|
||||
| "enhancedPrompt"
|
||||
| "commitSearchResults"
|
||||
| "listApiConfig"
|
||||
| "routerModels"
|
||||
| "openAiModels"
|
||||
| "ollamaModels"
|
||||
| "lmStudioModels"
|
||||
| "vsCodeLmModels"
|
||||
| "huggingFaceModels"
|
||||
| "vsCodeLmApiAvailable"
|
||||
| "updatePrompt"
|
||||
| "systemPrompt"
|
||||
| "autoApprovalEnabled"
|
||||
| "updateCustomMode"
|
||||
| "deleteCustomMode"
|
||||
| "exportModeResult"
|
||||
| "importModeResult"
|
||||
| "checkRulesDirectoryResult"
|
||||
| "deleteCustomModeCheck"
|
||||
| "currentCheckpointUpdated"
|
||||
| "checkpointInitWarning"
|
||||
| "browserToolEnabled"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
| "ttsStart"
|
||||
| "ttsStop"
|
||||
| "maxReadFileLine"
|
||||
| "fileSearchResults"
|
||||
| "toggleApiConfigPin"
|
||||
| "acceptInput"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "commandExecutionStatus"
|
||||
| "mcpExecutionStatus"
|
||||
| "vsCodeSetting"
|
||||
| "authenticatedUser"
|
||||
| "condenseTaskContextStarted"
|
||||
| "condenseTaskContextResponse"
|
||||
| "singleRouterModelFetchResponse"
|
||||
| "rooCreditBalance"
|
||||
| "indexingStatusUpdate"
|
||||
| "indexCleared"
|
||||
| "codebaseIndexConfig"
|
||||
| "marketplaceInstallResult"
|
||||
| "marketplaceRemoveResult"
|
||||
| "marketplaceData"
|
||||
| "shareTaskSuccess"
|
||||
| "codeIndexSettingsSaved"
|
||||
| "codeIndexSecretStatus"
|
||||
| "showDeleteMessageDialog"
|
||||
| "showEditMessageDialog"
|
||||
| "commands"
|
||||
| "insertTextIntoTextarea"
|
||||
| "dismissedUpsells"
|
||||
| "organizationSwitchResult"
|
||||
| "interactionRequired"
|
||||
| "browserSessionUpdate"
|
||||
| "browserSessionNavigate"
|
||||
| "claudeCodeRateLimits"
|
||||
| "customToolsResult"
|
||||
| "modes"
|
||||
text?: string
|
||||
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
checkpointWarning?: {
|
||||
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
|
||||
timeout: number
|
||||
}
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
| "settingsButtonClicked"
|
||||
| "historyButtonClicked"
|
||||
| "marketplaceButtonClicked"
|
||||
| "cloudButtonClicked"
|
||||
| "didBecomeVisible"
|
||||
| "focusInput"
|
||||
| "switchTab"
|
||||
| "toggleAutoApprove"
|
||||
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
filePaths?: string[]
|
||||
openedTabs?: Array<{
|
||||
label: string
|
||||
isActive: boolean
|
||||
path?: string
|
||||
}>
|
||||
clineMessage?: ClineMessage
|
||||
routerModels?: RouterModels
|
||||
openAiModels?: string[]
|
||||
ollamaModels?: ModelRecord
|
||||
lmStudioModels?: ModelRecord
|
||||
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
|
||||
huggingFaceModels?: Array<{
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
owned_by: string
|
||||
providers: Array<{
|
||||
provider: string
|
||||
status: "live" | "staging" | "error"
|
||||
supports_tools?: boolean
|
||||
supports_structured_output?: boolean
|
||||
context_length?: number
|
||||
pricing?: {
|
||||
input: number
|
||||
output: number
|
||||
}
|
||||
}>
|
||||
}>
|
||||
mcpServers?: McpServer[]
|
||||
commits?: GitCommit[]
|
||||
listApiConfig?: ProviderSettingsEntry[]
|
||||
mode?: string
|
||||
customMode?: ModeConfig
|
||||
slug?: string
|
||||
success?: boolean
|
||||
values?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
requestId?: string
|
||||
promptText?: string
|
||||
results?:
|
||||
| { path: string; type: "file" | "folder"; label?: string }[]
|
||||
| { name: string; description?: string; argumentHint?: string; source: "global" | "project" | "built-in" }[]
|
||||
error?: string
|
||||
setting?: string
|
||||
value?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
hasContent?: boolean
|
||||
items?: MarketplaceItem[]
|
||||
userInfo?: CloudUserInfo
|
||||
organizationAllowList?: OrganizationAllowList
|
||||
tab?: string
|
||||
marketplaceItems?: MarketplaceItem[]
|
||||
organizationMcps?: MarketplaceItem[]
|
||||
marketplaceInstalledMetadata?: MarketplaceInstalledMetadata
|
||||
errors?: string[]
|
||||
visibility?: ShareVisibility
|
||||
rulesFolderPath?: string
|
||||
settings?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
messageTs?: number
|
||||
hasCheckpoint?: boolean
|
||||
context?: string
|
||||
commands?: Command[]
|
||||
queuedMessages?: QueuedMessage[]
|
||||
list?: string[] // For dismissedUpsells
|
||||
organizationId?: string | null // For organizationSwitchResult
|
||||
browserSessionMessages?: ClineMessage[] // For browser session panel updates
|
||||
isBrowserSessionActive?: boolean // For browser session panel updates
|
||||
stepIndex?: number // For browserSessionNavigate: the target step index to display
|
||||
tools?: SerializedCustomToolDefinition[] // For customToolsResult
|
||||
modes?: { slug: string; name: string }[] // For modes response
|
||||
}
|
||||
|
||||
export type ExtensionState = Pick<
|
||||
GlobalSettings,
|
||||
| "currentApiConfigName"
|
||||
| "listApiConfigMeta"
|
||||
| "pinnedApiConfigs"
|
||||
| "customInstructions"
|
||||
| "dismissedUpsells"
|
||||
| "autoApprovalEnabled"
|
||||
| "alwaysAllowReadOnly"
|
||||
| "alwaysAllowReadOnlyOutsideWorkspace"
|
||||
| "alwaysAllowWrite"
|
||||
| "alwaysAllowWriteOutsideWorkspace"
|
||||
| "alwaysAllowWriteProtected"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "alwaysAllowModeSwitch"
|
||||
| "alwaysAllowSubtasks"
|
||||
| "alwaysAllowFollowupQuestions"
|
||||
| "alwaysAllowExecute"
|
||||
| "followupAutoApproveTimeoutMs"
|
||||
| "allowedCommands"
|
||||
| "deniedCommands"
|
||||
| "allowedMaxRequests"
|
||||
| "allowedMaxCost"
|
||||
| "browserToolEnabled"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "remoteBrowserEnabled"
|
||||
| "cachedChromeHostUrl"
|
||||
| "remoteBrowserHost"
|
||||
| "ttsEnabled"
|
||||
| "ttsSpeed"
|
||||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "maxConcurrentFileReads"
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalOutputCharacterLimit"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
| "terminalCommandDelay"
|
||||
| "terminalPowershellCounter"
|
||||
| "terminalZshClearEolMark"
|
||||
| "terminalZshOhMy"
|
||||
| "terminalZshP10k"
|
||||
| "terminalZdotdir"
|
||||
| "terminalCompressProgressBar"
|
||||
| "diagnosticsEnabled"
|
||||
| "diffEnabled"
|
||||
| "fuzzyMatchThreshold"
|
||||
| "language"
|
||||
| "modeApiConfigs"
|
||||
| "customModePrompts"
|
||||
| "customSupportPrompts"
|
||||
| "enhancementApiConfigId"
|
||||
| "condensingApiConfigId"
|
||||
| "customCondensingPrompt"
|
||||
| "codebaseIndexConfig"
|
||||
| "codebaseIndexModels"
|
||||
| "profileThresholds"
|
||||
| "includeDiagnosticMessages"
|
||||
| "maxDiagnosticMessages"
|
||||
| "imageGenerationProvider"
|
||||
| "openRouterImageGenerationSelectedModel"
|
||||
| "includeTaskHistoryInEnhance"
|
||||
| "reasoningBlockCollapsed"
|
||||
| "enterBehavior"
|
||||
| "includeCurrentTime"
|
||||
| "includeCurrentCost"
|
||||
| "maxGitStatusFiles"
|
||||
| "requestDelaySeconds"
|
||||
> & {
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
currentTaskTodos?: TodoItem[] // Initial todos for the current task
|
||||
apiConfiguration: ProviderSettings
|
||||
uriScheme?: string
|
||||
shouldShowAnnouncement: boolean
|
||||
|
||||
taskHistory: HistoryItem[]
|
||||
|
||||
writeDelayMs: number
|
||||
|
||||
enableCheckpoints: boolean
|
||||
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
|
||||
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
|
||||
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
|
||||
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
|
||||
enableSubfolderRules: boolean // Whether to load rules from subdirectories
|
||||
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
|
||||
maxImageFileSize: number // Maximum size of image files to process in MB
|
||||
maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB
|
||||
|
||||
experiments: Experiments // Map of experiment IDs to their enabled state
|
||||
|
||||
mcpEnabled: boolean
|
||||
enableMcpServerCreation: boolean
|
||||
|
||||
mode: string
|
||||
customModes: ModeConfig[]
|
||||
toolRequirements?: Record<string, boolean> // Map of tool names to their requirements (e.g. {"apply_diff": true} if diffEnabled)
|
||||
|
||||
cwd?: string // Current working directory
|
||||
telemetrySetting: TelemetrySetting
|
||||
telemetryKey?: string
|
||||
machineId?: string
|
||||
|
||||
renderContext: "sidebar" | "editor"
|
||||
settingsImportedAt?: number
|
||||
historyPreviewCollapsed?: boolean
|
||||
|
||||
cloudUserInfo: CloudUserInfo | null
|
||||
cloudIsAuthenticated: boolean
|
||||
cloudAuthSkipModel?: boolean // Flag indicating auth completed without model selection (user should pick 3rd-party provider)
|
||||
cloudApiUrl?: string
|
||||
cloudOrganizations?: CloudOrganizationMembership[]
|
||||
sharingEnabled: boolean
|
||||
publicSharingEnabled: boolean
|
||||
organizationAllowList: OrganizationAllowList
|
||||
organizationSettingsVersion?: number
|
||||
|
||||
isBrowserSessionActive: boolean // Actual browser session state
|
||||
|
||||
autoCondenseContext: boolean
|
||||
autoCondenseContextPercent: number
|
||||
marketplaceItems?: MarketplaceItem[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
|
||||
profileThresholds: Record<string, number>
|
||||
hasOpenedModeSelector: boolean
|
||||
openRouterImageApiKey?: string
|
||||
messageQueue?: QueuedMessage[]
|
||||
lastShownAnnouncementId?: string
|
||||
apiModelId?: string
|
||||
mcpServers?: McpServer[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
mdmCompliant?: boolean
|
||||
remoteControlEnabled: boolean
|
||||
taskSyncEnabled: boolean
|
||||
featureRoomoteControlEnabled: boolean
|
||||
claudeCodeIsAuthenticated?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string
|
||||
source: "global" | "project" | "built-in"
|
||||
filePath?: string
|
||||
description?: string
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* WebviewMessage
|
||||
* Webview | CLI -> Extension
|
||||
*/
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
|
||||
|
||||
export type AudioType = "notification" | "celebration" | "progress_loop"
|
||||
|
||||
export interface UpdateTodoListPayload {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
todos: any[]
|
||||
}
|
||||
|
||||
export type EditQueuedMessagePayload = Pick<QueuedMessage, "id" | "text" | "images">
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "updateTodoList"
|
||||
| "deleteMultipleTasksWithIds"
|
||||
| "currentApiConfigName"
|
||||
| "saveApiConfiguration"
|
||||
| "upsertApiConfiguration"
|
||||
| "deleteApiConfiguration"
|
||||
| "loadApiConfiguration"
|
||||
| "loadApiConfigurationById"
|
||||
| "renameApiConfiguration"
|
||||
| "getListApiConfiguration"
|
||||
| "customInstructions"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
| "askResponse"
|
||||
| "terminalOperation"
|
||||
| "clearTask"
|
||||
| "didShowAnnouncement"
|
||||
| "selectImages"
|
||||
| "exportCurrentTask"
|
||||
| "shareCurrentTask"
|
||||
| "showTaskWithId"
|
||||
| "deleteTaskWithId"
|
||||
| "exportTaskWithId"
|
||||
| "importSettings"
|
||||
| "exportSettings"
|
||||
| "resetState"
|
||||
| "flushRouterModels"
|
||||
| "requestRouterModels"
|
||||
| "requestOpenAiModels"
|
||||
| "requestOllamaModels"
|
||||
| "requestLmStudioModels"
|
||||
| "requestRooModels"
|
||||
| "requestRooCreditBalance"
|
||||
| "requestVsCodeLmModels"
|
||||
| "requestHuggingFaceModels"
|
||||
| "openImage"
|
||||
| "saveImage"
|
||||
| "openFile"
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
| "cancelAutoApproval"
|
||||
| "updateVSCodeSetting"
|
||||
| "getVSCodeSetting"
|
||||
| "vsCodeSetting"
|
||||
| "updateCondensingPrompt"
|
||||
| "playSound"
|
||||
| "playTts"
|
||||
| "stopTts"
|
||||
| "ttsEnabled"
|
||||
| "ttsSpeed"
|
||||
| "openKeyboardShortcuts"
|
||||
| "openMcpSettings"
|
||||
| "openProjectMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "refreshAllMcpServers"
|
||||
| "toggleToolAlwaysAllow"
|
||||
| "toggleToolEnabledForPrompt"
|
||||
| "toggleMcpServer"
|
||||
| "updateMcpTimeout"
|
||||
| "enhancePrompt"
|
||||
| "enhancedPrompt"
|
||||
| "draggedImages"
|
||||
| "deleteMessage"
|
||||
| "deleteMessageConfirm"
|
||||
| "submitEditedMessage"
|
||||
| "editMessageConfirm"
|
||||
| "enableMcpServerCreation"
|
||||
| "remoteControlEnabled"
|
||||
| "taskSyncEnabled"
|
||||
| "searchCommits"
|
||||
| "setApiConfigPassword"
|
||||
| "mode"
|
||||
| "updatePrompt"
|
||||
| "getSystemPrompt"
|
||||
| "copySystemPrompt"
|
||||
| "systemPrompt"
|
||||
| "enhancementApiConfigId"
|
||||
| "autoApprovalEnabled"
|
||||
| "updateCustomMode"
|
||||
| "deleteCustomMode"
|
||||
| "setopenAiCustomModelInfo"
|
||||
| "openCustomModesSettings"
|
||||
| "checkpointDiff"
|
||||
| "checkpointRestore"
|
||||
| "deleteMcpServer"
|
||||
| "codebaseIndexEnabled"
|
||||
| "telemetrySetting"
|
||||
| "testBrowserConnection"
|
||||
| "browserConnectionResult"
|
||||
| "searchFiles"
|
||||
| "toggleApiConfigPin"
|
||||
| "hasOpenedModeSelector"
|
||||
| "clearCloudAuthSkipModel"
|
||||
| "cloudButtonClicked"
|
||||
| "rooCloudSignIn"
|
||||
| "cloudLandingPageSignIn"
|
||||
| "rooCloudSignOut"
|
||||
| "rooCloudManualUrl"
|
||||
| "claudeCodeSignIn"
|
||||
| "claudeCodeSignOut"
|
||||
| "switchOrganization"
|
||||
| "condenseTaskContextRequest"
|
||||
| "requestIndexingStatus"
|
||||
| "startIndexing"
|
||||
| "clearIndexData"
|
||||
| "indexingStatusUpdate"
|
||||
| "indexCleared"
|
||||
| "focusPanelRequest"
|
||||
| "openExternal"
|
||||
| "filterMarketplaceItems"
|
||||
| "marketplaceButtonClicked"
|
||||
| "installMarketplaceItem"
|
||||
| "installMarketplaceItemWithParameters"
|
||||
| "cancelMarketplaceInstall"
|
||||
| "removeInstalledMarketplaceItem"
|
||||
| "marketplaceInstallResult"
|
||||
| "fetchMarketplaceData"
|
||||
| "switchTab"
|
||||
| "shareTaskSuccess"
|
||||
| "exportMode"
|
||||
| "exportModeResult"
|
||||
| "importMode"
|
||||
| "importModeResult"
|
||||
| "checkRulesDirectory"
|
||||
| "checkRulesDirectoryResult"
|
||||
| "saveCodeIndexSettingsAtomic"
|
||||
| "requestCodeIndexSecretStatus"
|
||||
| "requestCommands"
|
||||
| "openCommandFile"
|
||||
| "deleteCommand"
|
||||
| "createCommand"
|
||||
| "insertTextIntoTextarea"
|
||||
| "showMdmAuthRequiredNotification"
|
||||
| "imageGenerationSettings"
|
||||
| "queueMessage"
|
||||
| "removeQueuedMessage"
|
||||
| "editQueuedMessage"
|
||||
| "dismissUpsell"
|
||||
| "getDismissedUpsells"
|
||||
| "updateSettings"
|
||||
| "allowedCommands"
|
||||
| "deniedCommands"
|
||||
| "killBrowserSession"
|
||||
| "openBrowserSessionPanel"
|
||||
| "showBrowserSessionPanelAtStep"
|
||||
| "refreshBrowserSessionPanel"
|
||||
| "browserPanelDidLaunch"
|
||||
| "openDebugApiHistory"
|
||||
| "openDebugUiHistory"
|
||||
| "downloadErrorDiagnostics"
|
||||
| "requestClaudeCodeRateLimits"
|
||||
| "refreshCustomTools"
|
||||
| "requestModes"
|
||||
| "switchMode"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
|
||||
disabled?: boolean
|
||||
context?: string
|
||||
dataUri?: string
|
||||
askResponse?: ClineAskResponse
|
||||
apiConfiguration?: ProviderSettings
|
||||
images?: string[]
|
||||
bool?: boolean
|
||||
value?: number
|
||||
stepIndex?: number
|
||||
isLaunchAction?: boolean
|
||||
forceShow?: boolean
|
||||
commands?: string[]
|
||||
audioType?: AudioType
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
alwaysAllow?: boolean
|
||||
isEnabled?: boolean
|
||||
mode?: string
|
||||
promptMode?: string | "enhance"
|
||||
customPrompt?: PromptComponent
|
||||
dataUrls?: string[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
values?: Record<string, any>
|
||||
query?: string
|
||||
setting?: string
|
||||
slug?: string
|
||||
modeConfig?: ModeConfig
|
||||
timeout?: number
|
||||
payload?: WebViewMessagePayload
|
||||
source?: "global" | "project"
|
||||
requestId?: string
|
||||
ids?: string[]
|
||||
hasSystemPromptOverride?: boolean
|
||||
terminalOperation?: "continue" | "abort"
|
||||
messageTs?: number
|
||||
restoreCheckpoint?: boolean
|
||||
historyPreviewCollapsed?: boolean
|
||||
filters?: { type?: string; search?: string; tags?: string[] }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings?: any
|
||||
url?: string // For openExternal
|
||||
mpItem?: MarketplaceItem
|
||||
mpInstallOptions?: InstallMarketplaceItemOptions
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
config?: Record<string, any> // Add config to the payload
|
||||
visibility?: ShareVisibility // For share visibility
|
||||
hasContent?: boolean // For checkRulesDirectoryResult
|
||||
checkOnly?: boolean // For deleteCustomMode check
|
||||
upsellId?: string // For dismissUpsell
|
||||
list?: string[] // For dismissedUpsells response
|
||||
organizationId?: string | null // For organization switching
|
||||
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
|
||||
codeIndexSettings?: {
|
||||
// Global state settings
|
||||
codebaseIndexEnabled: boolean
|
||||
codebaseIndexQdrantUrl: string
|
||||
codebaseIndexEmbedderProvider:
|
||||
| "openai"
|
||||
| "ollama"
|
||||
| "openai-compatible"
|
||||
| "gemini"
|
||||
| "mistral"
|
||||
| "vercel-ai-gateway"
|
||||
| "bedrock"
|
||||
| "openrouter"
|
||||
codebaseIndexEmbedderBaseUrl?: string
|
||||
codebaseIndexEmbedderModelId: string
|
||||
codebaseIndexEmbedderModelDimension?: number // Generic dimension for all providers
|
||||
codebaseIndexOpenAiCompatibleBaseUrl?: string
|
||||
codebaseIndexBedrockRegion?: string
|
||||
codebaseIndexBedrockProfile?: string
|
||||
codebaseIndexSearchMaxResults?: number
|
||||
codebaseIndexSearchMinScore?: number
|
||||
codebaseIndexOpenRouterSpecificProvider?: string // OpenRouter provider routing
|
||||
|
||||
// Secret settings
|
||||
codeIndexOpenAiKey?: string
|
||||
codeIndexQdrantApiKey?: string
|
||||
codebaseIndexOpenAiCompatibleApiKey?: string
|
||||
codebaseIndexGeminiApiKey?: string
|
||||
codebaseIndexMistralApiKey?: string
|
||||
codebaseIndexVercelAiGatewayApiKey?: string
|
||||
codebaseIndexOpenRouterApiKey?: string
|
||||
}
|
||||
updatedSettings?: RooCodeSettings
|
||||
}
|
||||
|
||||
export const checkoutDiffPayloadSchema = z.object({
|
||||
ts: z.number().optional(),
|
||||
previousCommitHash: z.string().optional(),
|
||||
commitHash: z.string(),
|
||||
mode: z.enum(["full", "checkpoint", "from-init", "to-current"]),
|
||||
})
|
||||
|
||||
export type CheckpointDiffPayload = z.infer<typeof checkoutDiffPayloadSchema>
|
||||
|
||||
export const checkoutRestorePayloadSchema = z.object({
|
||||
ts: z.number(),
|
||||
commitHash: z.string(),
|
||||
mode: z.enum(["preview", "restore"]),
|
||||
})
|
||||
|
||||
export type CheckpointRestorePayload = z.infer<typeof checkoutRestorePayloadSchema>
|
||||
|
||||
export interface IndexingStatusPayload {
|
||||
state: "Standby" | "Indexing" | "Indexed" | "Error"
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IndexClearedPayload {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const installMarketplaceItemWithParametersPayloadSchema = z.object({
|
||||
item: marketplaceItemSchema,
|
||||
parameters: z.record(z.string(), z.any()),
|
||||
})
|
||||
|
||||
export type InstallMarketplaceItemWithParametersPayload = z.infer<
|
||||
typeof installMarketplaceItemWithParametersPayloadSchema
|
||||
>
|
||||
|
||||
export type WebViewMessagePayload =
|
||||
| CheckpointDiffPayload
|
||||
| CheckpointRestorePayload
|
||||
| IndexingStatusPayload
|
||||
| IndexClearedPayload
|
||||
| InstallMarketplaceItemWithParametersPayload
|
||||
| UpdateTodoListPayload
|
||||
| EditQueuedMessagePayload
|
||||
|
|
@ -68,6 +68,13 @@ export interface VSCodeAPIMockOptions {
|
|||
* Defaults to the directory containing this module.
|
||||
*/
|
||||
appRoot?: string
|
||||
|
||||
/**
|
||||
* Custom storage directory for persistent state.
|
||||
* Defaults to ~/.vscode-mock.
|
||||
* Set to a temp directory for ephemeral/no-persist mode.
|
||||
*/
|
||||
storageDir?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,6 +89,7 @@ export function createVSCodeAPIMock(
|
|||
const context = new ExtensionContextImpl({
|
||||
extensionPath: extensionRootPath,
|
||||
workspacePath: workspacePath,
|
||||
storageDir: options?.storageDir,
|
||||
})
|
||||
const workspace = new WorkspaceAPI(workspacePath, context)
|
||||
const window = new WindowAPI()
|
||||
|
|
|
|||
503
pnpm-lock.yaml
generated
503
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,14 +3,13 @@ import { z } from "zod"
|
|||
|
||||
import {
|
||||
type ModelInfo,
|
||||
type ModelRecord,
|
||||
HUGGINGFACE_API_URL,
|
||||
HUGGINGFACE_CACHE_DURATION,
|
||||
HUGGINGFACE_DEFAULT_MAX_TOKENS,
|
||||
HUGGINGFACE_DEFAULT_CONTEXT_WINDOW,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
|
||||
const huggingFaceProviderSchema = z.object({
|
||||
provider: z.string(),
|
||||
status: z.enum(["live", "staging", "error"]),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import axios from "axios"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ModelInfo, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
|
||||
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
import { type ModelInfo, type ModelRecord, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
|
||||
|
||||
const ioIntelligenceModelSchema = z.object({
|
||||
id: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import axios from "axios"
|
||||
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
import type { ModelRecord } from "@roo-code/types"
|
||||
|
||||
import { DEFAULT_HEADERS } from "../constants"
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as fsSync from "fs"
|
|||
import NodeCache from "node-cache"
|
||||
import { z } from "zod"
|
||||
|
||||
import type { ProviderName } from "@roo-code/types"
|
||||
import type { ProviderName, ModelRecord } from "@roo-code/types"
|
||||
import { modelInfoSchema, TelemetryEventName } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson"
|
|||
|
||||
import { ContextProxy } from "../../../core/config/ContextProxy"
|
||||
import { getCacheDirectoryPath } from "../../../utils/storage"
|
||||
import type { RouterName, ModelRecord } from "../../../shared/api"
|
||||
import type { RouterName } from "../../../shared/api"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
import { getOpenRouterModels } from "./openrouter"
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ import * as path from "path"
|
|||
import fs from "fs/promises"
|
||||
|
||||
import NodeCache from "node-cache"
|
||||
import { safeWriteJson } from "../../../utils/safeWriteJson"
|
||||
import sanitize from "sanitize-filename"
|
||||
|
||||
import type { ModelRecord } from "@roo-code/types"
|
||||
|
||||
import { ContextProxy } from "../../../core/config/ContextProxy"
|
||||
import { RouterName } from "../../../shared/api"
|
||||
import { getCacheDirectoryPath } from "../../../utils/storage"
|
||||
import { RouterName, ModelRecord } from "../../../shared/api"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
import { safeWriteJson } from "../../../utils/safeWriteJson"
|
||||
|
||||
import { getOpenRouterModelEndpoints } from "./openrouter"
|
||||
import { getModels } from "./modelCache"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { RooModelsResponseSchema, type ModelInfo } from "@roo-code/types"
|
||||
import { RooModelsResponseSchema, type ModelInfo, type ModelRecord } from "@roo-code/types"
|
||||
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
import { parseApiPrice } from "../../../shared/cost"
|
||||
|
||||
import { DEFAULT_HEADERS } from "../constants"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
|
||||
import type { ModelRecord } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@ import OpenAI from "openai"
|
|||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
type ModelRecord,
|
||||
ApiProviderError,
|
||||
openRouterDefaultModelId,
|
||||
openRouterDefaultModelInfo,
|
||||
OPENROUTER_DEFAULT_PROVIDER_NAME,
|
||||
OPEN_ROUTER_PROMPT_CACHING_MODELS,
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
ApiProviderError,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
|
||||
|
||||
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { normalizeMistralToolCallId } from "../transform/mistral-format"
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import OpenAI from "openai"
|
|||
|
||||
import {
|
||||
type ModelInfo,
|
||||
type ModelRecord,
|
||||
requestyDefaultModelId,
|
||||
requestyDefaultModelInfo,
|
||||
TOOL_PROTOCOL,
|
||||
NATIVE_TOOL_DEFAULTS,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
|
||||
import { calculateApiCostOpenAI } from "../../shared/cost"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
|||
import OpenAI from "openai"
|
||||
|
||||
import { rooDefaultModelId, getApiProtocol, type ImageGenerationApiMethod } from "@roo-code/types"
|
||||
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
|
||||
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
|
||||
|
||||
import { Package } from "../../shared/package"
|
||||
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import OpenAI from "openai"
|
||||
|
||||
import { type ModelInfo, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
|
||||
import { type ModelInfo, type ModelRecord, NATIVE_TOOL_DEFAULTS } from "@roo-code/types"
|
||||
|
||||
import { ApiHandlerOptions, RouterName, ModelRecord } from "../../shared/api"
|
||||
import { ApiHandlerOptions, RouterName } from "../../shared/api"
|
||||
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import { getModels, getModelsFromCache } from "./fetchers/modelCache"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { type ClineAsk, type McpServerUse, type FollowUpData, isNonBlockingAsk } from "@roo-code/types"
|
||||
import {
|
||||
type ClineAsk,
|
||||
type McpServerUse,
|
||||
type FollowUpData,
|
||||
type ExtensionState,
|
||||
isNonBlockingAsk,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import type { ClineSayTool, ExtensionState } from "../../shared/ExtensionMessage"
|
||||
import type { ClineSayTool } from "../../shared/ExtensionMessage"
|
||||
import { ClineAskResponse } from "../../shared/WebviewMessage"
|
||||
|
||||
import { isWriteToolAction, isReadOnlyToolAction } from "./tools"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type { McpServerUse } from "@roo-code/types"
|
||||
|
||||
import type { McpServer, McpTool } from "../../shared/mcp"
|
||||
import type { McpServerUse, McpServer, McpTool } from "@roo-code/types"
|
||||
|
||||
export function isMcpToolAlwaysAllowed(mcpServerUse: McpServerUse, mcpServers: McpServer[] | undefined): boolean {
|
||||
if (mcpServerUse.type === "use_mcp_tool" && mcpServerUse.toolName) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type OpenAI from "openai"
|
||||
import { getMcpServerTools } from "../mcp_server"
|
||||
|
||||
import type { McpServer, McpTool } from "@roo-code/types"
|
||||
|
||||
import type { McpHub } from "../../../../../services/mcp/McpHub"
|
||||
import type { McpServer, McpTool } from "../../../../../shared/mcp"
|
||||
|
||||
import { getMcpServerTools } from "../mcp_server"
|
||||
|
||||
// Helper type to access function tools
|
||||
type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }
|
||||
|
|
|
|||
|
|
@ -1688,6 +1688,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
// Reset abort and streaming state to ensure clean continuation.
|
||||
// This matches the behavior in resumeAfterDelegation() and prevents
|
||||
// corrupted state from a previous cancellation.
|
||||
this.abort = false
|
||||
this.abandoned = false
|
||||
this.abortReason = undefined
|
||||
this.didFinishAbortingStream = false
|
||||
this.isStreaming = false
|
||||
this.isWaitingForFirstChunk = false
|
||||
|
||||
if (this.enableBridge) {
|
||||
try {
|
||||
await BridgeOrchestrator.subscribeToTask(this)
|
||||
|
|
@ -1788,7 +1798,22 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
this.isInitialized = true
|
||||
|
||||
const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
|
||||
let response: ClineAskResponse
|
||||
let text: string | undefined
|
||||
let images: string[] | undefined
|
||||
|
||||
try {
|
||||
const result = await this.ask(askType) // Calls `postStateToWebview`.
|
||||
response = result.response
|
||||
text = result.text
|
||||
images = result.images
|
||||
} catch (error) {
|
||||
// Handle abort gracefully - if task was aborted during the ask, don't throw
|
||||
if (this.abort) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let responseText: string | undefined
|
||||
let responseImages: string[] | undefined
|
||||
|
|
@ -1973,7 +1998,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
||||
|
||||
// Task resuming from history item.
|
||||
await this.initiateTaskLoop(newUserContent)
|
||||
await this.initiateTaskLoop(newUserContent).catch((error) => {
|
||||
// Swallow loop rejection when the task was intentionally abandoned/aborted
|
||||
// during delegation or user cancellation to prevent unhandled rejections.
|
||||
if (this.abandoned === true || this.abortReason === "user_cancelled") {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ import {
|
|||
type CreateTaskOptions,
|
||||
type TokenUsage,
|
||||
type ToolUsage,
|
||||
type ExtensionMessage,
|
||||
type ExtensionState,
|
||||
type MarketplaceInstalledMetadata,
|
||||
RooCodeEventName,
|
||||
requestyDefaultModelId,
|
||||
openRouterDefaultModelId,
|
||||
|
|
@ -51,7 +54,6 @@ import { Package } from "../../shared/package"
|
|||
import { findLast } from "../../shared/array"
|
||||
import { supportPrompt } from "../../shared/support-prompt"
|
||||
import { GlobalFileNames } from "../../shared/globalFileNames"
|
||||
import type { ExtensionMessage, ExtensionState, MarketplaceInstalledMetadata } from "../../shared/ExtensionMessage"
|
||||
import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes"
|
||||
import { experimentDefault } from "../../shared/experiments"
|
||||
import { formatLanguage } from "../../shared/language"
|
||||
|
|
@ -902,7 +904,18 @@ export class ClineProvider
|
|||
|
||||
if (profile?.name) {
|
||||
try {
|
||||
await this.activateProviderProfile({ name: profile.name })
|
||||
// Check if the profile has actual API configuration (not just an id).
|
||||
// In CLI mode, the ProviderSettingsManager may return empty default profiles
|
||||
// that only contain 'id' and 'name' fields. Activating such a profile would
|
||||
// overwrite the CLI's working API configuration with empty settings.
|
||||
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
|
||||
const hasActualSettings = !!fullProfile.apiProvider
|
||||
|
||||
if (hasActualSettings) {
|
||||
await this.activateProviderProfile({ name: profile.name })
|
||||
} else {
|
||||
// The task will continue with the current/default configuration.
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but continue with task restoration.
|
||||
this.log(
|
||||
|
|
@ -910,7 +923,6 @@ export class ClineProvider
|
|||
error instanceof Error ? error.message : String(error)
|
||||
}. Continuing with default configuration.`,
|
||||
)
|
||||
// The task will continue with the current/default configuration.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1285,14 +1297,29 @@ export class ClineProvider
|
|||
const profile = listApiConfig.find(({ id }) => id === savedConfigId)
|
||||
|
||||
if (profile?.name) {
|
||||
await this.activateProviderProfile({ name: profile.name })
|
||||
// Check if the profile has actual API configuration (not just an id).
|
||||
// In CLI mode, the ProviderSettingsManager may return empty default profiles
|
||||
// that only contain 'id' and 'name' fields. Activating such a profile would
|
||||
// overwrite the CLI's working API configuration with empty settings.
|
||||
// Skip activation if the profile has no apiProvider set - this indicates
|
||||
// an unconfigured/empty profile.
|
||||
const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name })
|
||||
const hasActualSettings = !!fullProfile.apiProvider
|
||||
|
||||
if (hasActualSettings) {
|
||||
await this.activateProviderProfile({ name: profile.name })
|
||||
} else {
|
||||
// The task will continue with the current/default configuration.
|
||||
}
|
||||
} else {
|
||||
// The task will continue with the current/default configuration.
|
||||
}
|
||||
} else {
|
||||
// If no saved config for this mode, save current config as default.
|
||||
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
|
||||
const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName")
|
||||
|
||||
if (currentApiConfigName) {
|
||||
const config = listApiConfig.find((c) => c.name === currentApiConfigName)
|
||||
if (currentApiConfigNameAfter) {
|
||||
const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter)
|
||||
|
||||
if (config?.id) {
|
||||
await this.providerSettingsManager.setModeConfig(newMode, config.id)
|
||||
|
|
@ -1453,6 +1480,7 @@ export class ClineProvider
|
|||
if (id) {
|
||||
await this.providerSettingsManager.setModeConfig(mode, id)
|
||||
}
|
||||
|
||||
// Change the provider for the current task.
|
||||
this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true })
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import axios from "axios"
|
|||
import {
|
||||
type ProviderSettingsEntry,
|
||||
type ClineMessage,
|
||||
type ExtensionMessage,
|
||||
type ExtensionState,
|
||||
ORGANIZATION_ALLOW_ALL,
|
||||
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { ExtensionMessage, ExtensionState } from "../../../shared/ExtensionMessage"
|
||||
import { defaultModeSlug } from "../../../shared/modes"
|
||||
import { experimentDefault } from "../../../shared/experiments"
|
||||
import { setTtsEnabled } from "../../../utils/tts"
|
||||
|
|
@ -888,6 +889,7 @@ describe("ClineProvider", () => {
|
|||
listConfig: vi.fn().mockResolvedValue([profile]),
|
||||
activateProfile: vi.fn().mockResolvedValue(profile),
|
||||
setModeConfig: vi.fn(),
|
||||
getProfile: vi.fn().mockResolvedValue(profile),
|
||||
} as any
|
||||
|
||||
// Switch to architect mode
|
||||
|
|
@ -1609,6 +1611,7 @@ describe("ClineProvider", () => {
|
|||
listConfig: vi.fn().mockResolvedValue([profile]),
|
||||
activateProfile: vi.fn().mockResolvedValue(profile),
|
||||
setModeConfig: vi.fn(),
|
||||
getProfile: vi.fn().mockResolvedValue(profile),
|
||||
} as any
|
||||
|
||||
// Switch to architect mode
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ vi.mock("../diagnosticsHandler", () => ({
|
|||
generateErrorDiagnostics: vi.fn().mockResolvedValue({ success: true, filePath: "/tmp/diagnostics.json" }),
|
||||
}))
|
||||
|
||||
import type { ModelRecord } from "@roo-code/types"
|
||||
|
||||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import type { ClineProvider } from "../ClineProvider"
|
||||
import { getModels } from "../../../api/providers/fetchers/modelCache"
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
|
||||
const mockGetModels = getModels as Mock<typeof getModels>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,14 @@ import {
|
|||
type ClineMessage,
|
||||
type TelemetrySetting,
|
||||
type UserSettingsConfig,
|
||||
type ModelRecord,
|
||||
type WebviewMessage,
|
||||
type EditQueuedMessagePayload,
|
||||
TelemetryEventName,
|
||||
RooCodeSettings,
|
||||
ExperimentId,
|
||||
checkoutDiffPayloadSchema,
|
||||
checkoutRestorePayloadSchema,
|
||||
} from "@roo-code/types"
|
||||
import { customToolRegistry } from "@roo-code/core"
|
||||
import { CloudService } from "@roo-code/cloud"
|
||||
|
|
@ -29,15 +34,9 @@ import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
|
|||
import { generateErrorDiagnostics } from "./diagnosticsHandler"
|
||||
import { changeLanguage, t } from "../../i18n"
|
||||
import { Package } from "../../shared/package"
|
||||
import { type RouterName, type ModelRecord, toRouterName } from "../../shared/api"
|
||||
import { type RouterName, toRouterName } from "../../shared/api"
|
||||
import { MessageEnhancer } from "./messageEnhancer"
|
||||
|
||||
import {
|
||||
type WebviewMessage,
|
||||
type EditQueuedMessagePayload,
|
||||
checkoutDiffPayloadSchema,
|
||||
checkoutRestorePayloadSchema,
|
||||
} from "../../shared/WebviewMessage"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { experimentDefault } from "../../shared/experiments"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
|
|
@ -2828,7 +2827,7 @@ export const webviewMessageHandler = async (
|
|||
|
||||
case "switchTab": {
|
||||
if (message.tab) {
|
||||
// Capture tab shown event for all switchTab messages (which are user-initiated)
|
||||
// Capture tab shown event for all switchTab messages (which are user-initiated).
|
||||
if (TelemetryService.hasInstance()) {
|
||||
TelemetryService.instance.captureTabShown(message.tab)
|
||||
}
|
||||
|
|
@ -2847,7 +2846,6 @@ export const webviewMessageHandler = async (
|
|||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(getCurrentCwd())
|
||||
|
||||
// Convert to the format expected by the frontend
|
||||
const commandList = commands.map((command) => ({
|
||||
name: command.name,
|
||||
source: command.source,
|
||||
|
|
@ -2856,17 +2854,26 @@ export const webviewMessageHandler = async (
|
|||
argumentHint: command.argumentHint,
|
||||
}))
|
||||
|
||||
await provider.postMessageToWebview({
|
||||
type: "commands",
|
||||
commands: commandList,
|
||||
})
|
||||
await provider.postMessageToWebview({ type: "commands", commands: commandList })
|
||||
} catch (error) {
|
||||
provider.log(`Error fetching commands: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
// Send empty array on error
|
||||
await provider.postMessageToWebview({
|
||||
type: "commands",
|
||||
commands: [],
|
||||
})
|
||||
await provider.postMessageToWebview({ type: "commands", commands: [] })
|
||||
}
|
||||
break
|
||||
}
|
||||
case "requestModes": {
|
||||
try {
|
||||
const modes = await provider.getModes()
|
||||
await provider.postMessageToWebview({ type: "modes", modes })
|
||||
} catch (error) {
|
||||
provider.log(`Error fetching modes: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`)
|
||||
await provider.postMessageToWebview({ type: "modes", modes: [] })
|
||||
}
|
||||
break
|
||||
}
|
||||
case "switchMode": {
|
||||
if (message.mode) {
|
||||
await provider.handleModeSwitch(message.mode as Mode)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue