mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4fd2402c2 | |||
| d1f8877600 | |||
| 08118d742b | |||
| c62a6616db | |||
| fbf0429434 | |||
| 0e2edb63f5 | |||
| 50b38e9081 | |||
| e9c3335656 | |||
| 3bf038ed7c | |||
| 408f8aa50f | |||
| 025ac7672f | |||
| aab6f4127c | |||
| c932cd0815 | |||
| d3395172f8 | |||
| 1e8851b244 | |||
| 167fb3f429 | |||
| df4d30addf | |||
| 37daf068c5 | |||
| 5452abe513 | |||
| a8b6406dac | |||
| 509d4a9513 | |||
| d099c21f5d | |||
| 4c89b5ad91 | |||
| 729c181313 |
@@ -18,7 +18,7 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME} openhands"
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
|
||||
|
||||
# Get the current PR body
|
||||
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Workflow that builds and tests the CLI binary executable
|
||||
name: CLI - Build and Test Binary
|
||||
|
||||
# Run on pushes to main branch and all pull requests, but only when CLI files change
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Build binary executable
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
./build.sh --install-pyinstaller | tee output.log
|
||||
echo "Full output:"
|
||||
cat output.log
|
||||
|
||||
if grep -q "❌" output.log; then
|
||||
echo "❌ Found failure marker in output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
@@ -73,6 +73,24 @@ jobs:
|
||||
working-directory: ./enterprise
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
lint-cli-python:
|
||||
name: Lint CLI python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==4.2.0
|
||||
- name: Run pre-commit hooks
|
||||
working-directory: ./openhands-cli
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Check version consistency across documentation
|
||||
check-version-consistency:
|
||||
name: Check version consistency
|
||||
|
||||
@@ -127,11 +127,58 @@ jobs:
|
||||
name: coverage-enterprise
|
||||
path: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
include-hidden-files: true
|
||||
|
||||
# Run CLI unit tests
|
||||
test-cli-python:
|
||||
name: CLI Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./openhands-cli
|
||||
run: |
|
||||
uv sync --group dev
|
||||
|
||||
- name: Run CLI unit tests
|
||||
working-directory: ./openhands-cli
|
||||
env:
|
||||
# write coverage to repo root so the merge step finds it
|
||||
COVERAGE_FILE: "${{ github.workspace }}/.coverage.openhands-cli.${{ matrix.python-version }}"
|
||||
run: |
|
||||
uv run pytest --forked -n auto -s \
|
||||
-p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark \
|
||||
tests --cov=openhands_cli --cov-branch
|
||||
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-openhands-cli
|
||||
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
|
||||
include-hidden-files: true
|
||||
|
||||
|
||||
coverage-comment:
|
||||
name: Coverage Comment
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-on-linux, test-enterprise]
|
||||
needs: [test-on-linux, test-enterprise, test-cli-python]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -145,6 +192,9 @@ jobs:
|
||||
pattern: coverage-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create symlink for CLI source files
|
||||
run: ln -sf openhands-cli/openhands_cli openhands_cli
|
||||
|
||||
- name: Coverage comment
|
||||
id: coverage_comment
|
||||
uses: py-cov-action/python-coverage-comment-action@v3
|
||||
|
||||
@@ -19,4 +19,4 @@ jobs:
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
operations-per-run: 150
|
||||
operations-per-run: 300
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
@@ -258,8 +258,4 @@ containers/runtime/code
|
||||
# test results
|
||||
test-results
|
||||
.sessions
|
||||
|
||||
# ignore agent-sdk embedded repo if present
|
||||
agent-sdk/
|
||||
|
||||
.eval_sessions
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.57-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.58-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://all-hands.dev/joinslack"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
|
||||
@@ -76,17 +76,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -139,7 +139,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Github:
|
||||
|
||||
- [Join our Slack workspace](https://dub.sh/openhands) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://all-hands.dev/joinslack) - Here we talk about research, architecture, and future development.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details on contributing in [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.57-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.58-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -28,12 +28,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+6
-9
@@ -3,18 +3,16 @@
|
||||
"theme": "mint",
|
||||
"name": "All Hands Docs",
|
||||
"colors": {
|
||||
"primary": "#99873c",
|
||||
"light": "#ffe165",
|
||||
"dark": "#ffe165"
|
||||
"primary": "#99873c"
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#f7f3ee",
|
||||
"dark": "#0B0D0E"
|
||||
"light": "#f7f3ee"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"default": "light"
|
||||
"default": "light",
|
||||
"strict": true
|
||||
},
|
||||
"favicon": "/logo-square.png",
|
||||
"navigation": {
|
||||
@@ -214,9 +212,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"slack": "https://dub.sh/openhands",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands",
|
||||
"discord": "https://discord.gg/ESHStjSjD4"
|
||||
"slack": "https://all-hands.dev/joinslack",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands"
|
||||
}
|
||||
},
|
||||
"contextual": {
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ description: OpenHands - Code Less, Make More
|
||||
icon: book-open
|
||||
mode: wide
|
||||
---
|
||||
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
|
||||
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer:
|
||||
they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
|
||||
|
||||
<iframe
|
||||
className="w-full aspect-video"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Reo.dev tracking initialization
|
||||
(function() {
|
||||
var e, t, n;
|
||||
e = "6bac7145b4ee6ec";
|
||||
t = function() {
|
||||
Reo.init({clientID: "6bac7145b4ee6ec"});
|
||||
};
|
||||
n = document.createElement("script");
|
||||
n.src = "https://static.reo.dev/" + e + "/reo.js";
|
||||
n.defer = true;
|
||||
n.onload = t;
|
||||
document.head.appendChild(n);
|
||||
})();
|
||||
|
||||
+73
-118
@@ -17,96 +17,87 @@ To use the OpenHands Cloud API, you'll need to generate an API key:
|
||||
4. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
|
||||
5. Copy the generated API key and store it securely. It will only be shown once.
|
||||
|
||||
## API Usage
|
||||
## API Usage Example
|
||||
|
||||
### Starting a New Conversation
|
||||
|
||||
To start a new conversation with OpenHands to perform a task, you'll need to make a POST request to the conversation endpoint.
|
||||
To start a new conversation with OpenHands to perform a task,
|
||||
[you'll need to make a POST request to the conversation endpoint](/api-reference/new-conversation).
|
||||
|
||||
#### Request Parameters
|
||||
<Tabs>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://app.all-hands.dev/api/conversations" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python (with requests)">
|
||||
```python
|
||||
import requests
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------------|----------|----------|------------------------------------------------------------------------------------------------------|
|
||||
| `initial_user_msg` | string | Yes | The initial message to start the conversation. |
|
||||
| `repository` | string | No | Git repository name to provide context in the format `owner/repo`. You must have access to the repo. |
|
||||
api_key = "YOUR_API_KEY"
|
||||
url = "https://app.all-hands.dev/api/conversations"
|
||||
|
||||
#### Examples
|
||||
|
||||
|
||||
<Accordion title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://app.all-hands.dev/api/conversations" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}'
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Python (with requests)">
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_key = "YOUR_API_KEY"
|
||||
url = "https://app.all-hands.dev/api/conversations"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
conversation = response.json()
|
||||
|
||||
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
|
||||
print(f"Status: {conversation['status']}")
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="TypeScript/JavaScript (with fetch)">
|
||||
```typescript
|
||||
const apiKey = "YOUR_API_KEY";
|
||||
const url = "https://app.all-hands.dev/api/conversations";
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
const data = {
|
||||
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
repository: "yourusername/your-repo"
|
||||
};
|
||||
|
||||
async function startConversation() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const conversation = await response.json();
|
||||
|
||||
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
|
||||
console.log(`Status: ${conversation.status}`);
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error("Error starting conversation:", error);
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
startConversation();
|
||||
```
|
||||
</Accordion>
|
||||
data = {
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
conversation = response.json()
|
||||
|
||||
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
|
||||
print(f"Status: {conversation['status']}")
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript/JavaScript (with fetch)">
|
||||
```typescript
|
||||
const apiKey = "YOUR_API_KEY";
|
||||
const url = "https://app.all-hands.dev/api/conversations";
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
const data = {
|
||||
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
repository: "yourusername/your-repo"
|
||||
};
|
||||
|
||||
async function startConversation() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const conversation = await response.json();
|
||||
|
||||
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
|
||||
console.log(`Status: ${conversation.status}`);
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error("Error starting conversation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
startConversation();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
#### Response
|
||||
|
||||
@@ -125,42 +116,6 @@ You may receive an `AuthenticationError` if:
|
||||
- You provided the wrong repository name.
|
||||
- You don't have access to the repository.
|
||||
|
||||
|
||||
### Retrieving Conversation Status
|
||||
|
||||
You can check the status of a conversation by making a GET request to the conversation endpoint.
|
||||
|
||||
#### Endpoint
|
||||
|
||||
```
|
||||
GET https://app.all-hands.dev/api/conversations/{conversation_id}
|
||||
```
|
||||
|
||||
#### Example
|
||||
|
||||
<Accordion title="cURL">
|
||||
```bash
|
||||
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
#### Response
|
||||
|
||||
The response is formatted as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id":"abc1234",
|
||||
"title":"Update README.md",
|
||||
"created_at":"2025-04-29T15:13:51.370706Z",
|
||||
"last_updated_at":"2025-04-29T15:13:57.199210Z",
|
||||
"status":"RUNNING",
|
||||
"selected_repository":"yourusername/your-repo",
|
||||
"trigger":"gui"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
If you have too many conversations running at once, older conversations will be paused to limit the number of concurrent conversations.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
title: "Pro Subscription"
|
||||
description: "Learn about OpenHands Cloud Pro Subscription features and pricing"
|
||||
description: "Learn about OpenHands Cloud Pro Subscription features and pricing."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenHands Pro Subscription unlocks additional features and better pricing when you run OpenHands conversations in
|
||||
OpenHands Cloud.
|
||||
|
||||
@@ -11,13 +13,13 @@ OpenHands Cloud.
|
||||
All users start on the Pay-as-you-go plan and have access to these base features when they sign up:
|
||||
|
||||
* **Run multiple OpenHands conversations on OpenHands Cloud runtimes.**
|
||||
* **API keys to the OpenHands LLM provider for use in OpenHands CLI or when running OpenHands on your own**
|
||||
* **API keys to the OpenHands LLM provider for use in OpenHands CLI or when running OpenHands on your own.**
|
||||
* **$20 in initial OpenHands Cloud credits to get started.**
|
||||
* **Support for GitHub, GitLab, Bitbucket, Slack, and more.**
|
||||
|
||||
## What you get with a Pro Subscription
|
||||
|
||||
The $20/month Pro Subscription covers the cost of runtime compute in OpenHands Cloud, plus enables the following
|
||||
The $20/month Pro subscription covers the cost of runtime compute in OpenHands Cloud, plus enables the following
|
||||
features:
|
||||
|
||||
* **Bring Your Own LLM Keys:** Bring your own API keys from OpenAI, Anthropic, Mistral, and other providers.
|
||||
|
||||
@@ -90,7 +90,6 @@ If you would like to set things up more systematically, you can:
|
||||
others have encountered the same problem.
|
||||
2. **Join our community**: Get help from other users and developers:
|
||||
- [Slack community](https://dub.sh/openhands)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
|
||||
[Troubleshooting](/usage/troubleshooting/troubleshooting).
|
||||
4. **Report bugs**: If you've found a bug, please [create an issue](https://github.com/All-Hands-AI/OpenHands/issues/new)
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
+11
-10
@@ -4,16 +4,17 @@ description: Running OpenHands Cloud or running on your own.
|
||||
icon: rocket
|
||||
---
|
||||
|
||||
## OpenHands Cloud
|
||||
<Tabs>
|
||||
<Tab title="OpenHands Cloud">
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
|
||||
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
|
||||
</Tab>
|
||||
<Tab title="Running OpenHands on Your Own">
|
||||
Run OpenHands on your local system and bring your own LLM and API key.
|
||||
|
||||
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
|
||||
|
||||
## Running OpenHands on Your Own
|
||||
|
||||
Run OpenHands on your local system and bring your own LLM and API key.
|
||||
|
||||
For more information see [running OpenHands on your own.](/usage/local-setup)
|
||||
For more information see [running OpenHands on your own.](/usage/local-setup)
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -119,7 +119,7 @@ When started for the first time, OpenHands will prompt you to set up the LLM pro
|
||||
|
||||
That's it! You can now start using OpenHands with the local LLM server.
|
||||
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands).
|
||||
|
||||
## Advanced: Alternative LLM Backends
|
||||
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -41,71 +41,71 @@ MCP configuration can be defined in:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### SSE Servers
|
||||
<Tabs>
|
||||
<Tab title="SSE Servers">
|
||||
SSE servers are configured using either a string URL or an object with the following properties:
|
||||
|
||||
SSE servers are configured using either a string URL or an object with the following properties:
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SSE server.
|
||||
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SSE server.
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
</Tab>
|
||||
<Tab title="SHTTP Servers">
|
||||
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SHTTP server.
|
||||
|
||||
#### SHTTP Servers
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
|
||||
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
|
||||
- `timeout` (optional)
|
||||
- Type: `int`
|
||||
- Default: `60`
|
||||
- Range: `1-3600` seconds (1 hour maximum)
|
||||
- Description: Timeout in seconds for tool execution. This prevents tool calls from hanging indefinitely.
|
||||
- **Use Cases:**
|
||||
- **Short timeout (1-30s)**: For lightweight operations like status checks or simple queries.
|
||||
- **Medium timeout (30-300s)**: For standard processing tasks like data analysis or API calls.
|
||||
- **Long timeout (300-3600s)**: For heavy operations like file processing, complex calculations, or batch operations.
|
||||
<Note>
|
||||
This timeout only applies to individual tool calls, not server connection establishment.
|
||||
</Note>
|
||||
</Tab>
|
||||
<Tab title="Stdio Servers">
|
||||
<Note>
|
||||
While stdio servers are supported, [we recommend using MCP proxies](/usage/settings/mcp-settings#configuration-examples) for
|
||||
better reliability and performance.
|
||||
</Note>
|
||||
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SHTTP server.
|
||||
Stdio servers are configured using an object with the following properties:
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
- `name` (required)
|
||||
- Type: `str`
|
||||
- Description: A unique name for the server.
|
||||
|
||||
- `timeout` (optional)
|
||||
- Type: `int`
|
||||
- Default: `60`
|
||||
- Range: `1-3600` seconds (1 hour maximum)
|
||||
- Description: Timeout in seconds for tool execution. This prevents tool calls from hanging indefinitely.
|
||||
- **Use Cases:**
|
||||
- **Short timeout (1-30s)**: For lightweight operations like status checks or simple queries.
|
||||
- **Medium timeout (30-300s)**: For standard processing tasks like data analysis or API calls.
|
||||
- **Long timeout (300-3600s)**: For heavy operations like file processing, complex calculations, or batch operations.
|
||||
<Note>
|
||||
This timeout only applies to individual tool calls, not server connection establishment.
|
||||
</Note>
|
||||
- `command` (required)
|
||||
- Type: `str`
|
||||
- Description: The command to run the server.
|
||||
|
||||
#### Stdio Servers
|
||||
- `args` (optional)
|
||||
- Type: `list of str`
|
||||
- Default: `[]`
|
||||
- Description: Command-line arguments to pass to the server.
|
||||
|
||||
<Note>
|
||||
While stdio servers are supported, [we recommend using MCP proxies](/usage/settings/mcp-settings#configuration-examples) for
|
||||
better reliability and performance.
|
||||
</Note>
|
||||
- `env` (optional)
|
||||
- Type: `dict of str to str`
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set for the server process.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Stdio servers are configured using an object with the following properties:
|
||||
|
||||
- `name` (required)
|
||||
- Type: `str`
|
||||
- Description: A unique name for the server.
|
||||
|
||||
- `command` (required)
|
||||
- Type: `str`
|
||||
- Description: The command to run the server.
|
||||
|
||||
- `args` (optional)
|
||||
- Type: `list of str`
|
||||
- Default: `[]`
|
||||
- Description: Command-line arguments to pass to the server.
|
||||
|
||||
- `env` (optional)
|
||||
- Type: `dict of str to str`
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set for the server process.
|
||||
|
||||
##### When to Use Direct Stdio
|
||||
#### When to Use Direct Stdio
|
||||
|
||||
Direct stdio connections may still be appropriate in these scenarios:
|
||||
- **Development and testing**: Quick prototyping of MCP servers.
|
||||
@@ -114,76 +114,78 @@ Direct stdio connections may still be appropriate in these scenarios:
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
#### Recommended: Using Proxy Servers (SSE/HTTP)
|
||||
<Tabs>
|
||||
<Tab title="Proxy Servers (SSE/HTTP) - Recommended">
|
||||
For stdio-based MCP servers, we recommend using MCP proxy tools like
|
||||
[`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
|
||||
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to
|
||||
HTTP/SSE endpoints.
|
||||
|
||||
For stdio-based MCP servers, we recommend using MCP proxy tools like
|
||||
[`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
|
||||
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to
|
||||
HTTP/SSE endpoints.
|
||||
Start the proxy servers separately:
|
||||
```bash
|
||||
# Terminal 1: Filesystem server proxy
|
||||
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
|
||||
|
||||
Start the proxy servers separately:
|
||||
```bash
|
||||
# Terminal 1: Filesystem server proxy
|
||||
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
|
||||
# Terminal 2: Fetch server proxy
|
||||
supergateway --stdio "uvx mcp-server-fetch" --port 8081
|
||||
```
|
||||
|
||||
# Terminal 2: Fetch server proxy
|
||||
supergateway --stdio "uvx mcp-server-fetch" --port 8081
|
||||
```
|
||||
Then configure OpenHands to use the HTTP endpoint:
|
||||
|
||||
Then configure OpenHands to use the HTTP endpoint:
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - Recommended approach using proxy tools
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://example.com:8080/mcp",
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - Recommended approach using proxy tools
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://example.com:8080/mcp",
|
||||
# SuperGateway proxy for fetch server
|
||||
"http://localhost:8081/sse",
|
||||
|
||||
# SuperGateway proxy for fetch server
|
||||
"http://localhost:8081/sse",
|
||||
# External MCP service with authentication
|
||||
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# External MCP service with authentication
|
||||
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
|
||||
]
|
||||
# SHTTP Servers - Modern streamable HTTP transport (recommended)
|
||||
shttp_servers = [
|
||||
# Basic SHTTP server with default 60s timeout
|
||||
"https://api.example.com/mcp/shttp",
|
||||
|
||||
# SHTTP Servers - Modern streamable HTTP transport (recommended)
|
||||
shttp_servers = [
|
||||
# Basic SHTTP server with default 60s timeout
|
||||
"https://api.example.com/mcp/shttp",
|
||||
|
||||
# Server with custom timeout for heavy operations
|
||||
{
|
||||
url = "https://files.example.com/mcp/shttp",
|
||||
api_key = "your-api-key",
|
||||
timeout = 1800 # 30 minutes for large file processing
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
# Direct stdio servers - use only for development/testing
|
||||
stdio_servers = [
|
||||
# Basic stdio server
|
||||
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
|
||||
|
||||
# Stdio server with environment variables
|
||||
{
|
||||
name="filesystem",
|
||||
command="npx",
|
||||
args=["@modelcontextprotocol/server-filesystem", "/"],
|
||||
env={
|
||||
"DEBUG": "true"
|
||||
# Server with custom timeout for heavy operations
|
||||
{
|
||||
url = "https://files.example.com/mcp/shttp",
|
||||
api_key = "your-api-key",
|
||||
timeout = 1800 # 30 minutes for large file processing
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
]
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Direct Stdio Servers">
|
||||
<Note>
|
||||
This setup is not Recommended for production.
|
||||
</Note>
|
||||
```toml
|
||||
[mcp]
|
||||
# Direct stdio servers - use only for development/testing
|
||||
stdio_servers = [
|
||||
# Basic stdio server
|
||||
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
|
||||
|
||||
For production use, we recommend using proxy tools like SuperGateway.
|
||||
# Stdio server with environment variables
|
||||
{
|
||||
name="filesystem",
|
||||
command="npx",
|
||||
args=["@modelcontextprotocol/server-filesystem", "/"],
|
||||
env={
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Other Proxy Tools
|
||||
For production use, we recommend using proxy tools like SuperGateway.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Other options include:
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
# Update packages with known CVE fixes
|
||||
pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
|
||||
Generated
+8
-7
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1061,7 +1061,7 @@ files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {main = "platform_system == \"Windows\" or os_name == \"nt\" or sys_platform == \"win32\"", dev = "os_name == \"nt\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\"", dev = "os_name == \"nt\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "comm"
|
||||
@@ -1990,6 +1990,7 @@ files = [
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b31dd488d0778c36f8279b306dc92a42f16904cba54acca71e107d65b60b0c"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b19361ee649365eefc717ec08005972d3d1eb9ee39908022d98e3bfa9da59e37"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8fc66b11423e6f3e1937385f655bedd67aebe56a3dcec0cb835351cfe7d358c9"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:2925f67b88d47cb16aa3eb1ab20fdcf21b94d74490e0818c91ea41434b987493"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7b15c54d300279ab20a9cc0579ada9c9f80d1bc92997fc61fb7bf3103d7cb26b"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458f1bc3ebbd76fdb89ad83e6b81ccd3b2a99fa6707cd3650b27606745cfb170"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:a8f0f83fbba6dc44271a11b22e15838641b8c45612cdf541b4822a5930f6893c"},
|
||||
@@ -6112,14 +6113,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "4.10.0"
|
||||
version = "6.7.6"
|
||||
description = "Integrate PostHog into any python application."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "posthog-4.10.0-py3-none-any.whl", hash = "sha256:b693d3d8209d000d8c5f4d6ea19096bfdfb83047fa8a14c937ae50a3394809a1"},
|
||||
{file = "posthog-4.10.0.tar.gz", hash = "sha256:513bfbb21344013294abc046b1142173189c5422a3906cf2280d1389b0c2e28b"},
|
||||
{file = "posthog-6.7.6-py3-none-any.whl", hash = "sha256:b09a7e65a042ec416c28874b397d3accae412a80a8b0ef3fa686fbffc99e4d4b"},
|
||||
{file = "posthog-6.7.6.tar.gz", hash = "sha256:ee5c5ad04b857d96d9b7a4f715e23916a2f206bfcf25e5a9d328a3d27664b0d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6128,11 +6129,11 @@ distro = ">=1.5.0"
|
||||
python-dateutil = ">=2.2"
|
||||
requests = ">=2.7,<3.0"
|
||||
six = ">=1.5"
|
||||
typing-extensions = ">=4.2.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
|
||||
langchain = ["langchain (>=0.2.0)"]
|
||||
sentry = ["django", "sentry-sdk"]
|
||||
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
|
||||
[[package]]
|
||||
@@ -10102,4 +10103,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "8c460070dce6bdec5ee0ee7bc0c2246fcf2602d1e64a0867b4f5e3a0e334fe93"
|
||||
content-hash = "fac67a8991a3e2c840a23702dc90f99e98d381f3537ad50b4c4739cdbde941ca"
|
||||
|
||||
@@ -38,7 +38,7 @@ resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
posthog = "^4.2.0"
|
||||
posthog = "^6.0.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
|
||||
@@ -21,6 +21,7 @@ from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
@@ -686,6 +687,7 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
url=self._get_conversation_url(conversation_id),
|
||||
session_api_key=None,
|
||||
event_store=EventStore(conversation_id, self.file_store, uid),
|
||||
runtime_status=RuntimeStatus.READY,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -174,19 +174,17 @@ async def keycloak_callback(
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.identify(
|
||||
posthog_user_id,
|
||||
{
|
||||
'$set': {
|
||||
'user_id': posthog_user_id, # Explicitly set as property
|
||||
'original_user_id': user_id, # Store the original user_id
|
||||
'is_feature_env': IS_FEATURE_ENV, # Track if this is a feature environment
|
||||
}
|
||||
posthog.set(
|
||||
distinct_id=posthog_user_id,
|
||||
properties={
|
||||
'user_id': posthog_user_id,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'auth:posthog_identify:failed',
|
||||
'auth:posthog_set:failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
|
||||
@@ -138,6 +138,7 @@ async def saas_search_repositories(
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -155,6 +156,7 @@ async def saas_search_repositories(
|
||||
per_page=per_page,
|
||||
sort=sort,
|
||||
order=order,
|
||||
selected_provider=selected_provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -60,9 +60,14 @@ from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
RUNTIME_URL_PATTERN = os.getenv(
|
||||
'RUNTIME_URL_PATTERN', 'https://{runtime_id}.prod-runtime.all-hands.dev'
|
||||
)
|
||||
RUNTIME_ROUTING_MODE = os.getenv('RUNTIME_ROUTING_MODE', 'subdomain').lower()
|
||||
|
||||
# Pattern for base URL for the runtime
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + '/api/conversations/{conversation_id}'
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
|
||||
'/runtime/api/conversations/{conversation_id}'
|
||||
if RUNTIME_ROUTING_MODE == 'path'
|
||||
else '/api/conversations/{conversation_id}'
|
||||
)
|
||||
|
||||
# Time in seconds before a Redis entry is considered expired if not refreshed
|
||||
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
|
||||
@@ -344,18 +349,48 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
api_url: str,
|
||||
custom_secrets: MappingProxyType[str, Any] | None,
|
||||
):
|
||||
"""Setup custom secrets for the nested conversation."""
|
||||
"""Setup custom secrets for the nested conversation.
|
||||
|
||||
Note: When resuming conversations, secrets may already exist in the runtime.
|
||||
We check for specific duplicate error messages to handle this case gracefully.
|
||||
"""
|
||||
if custom_secrets:
|
||||
for key, secret in custom_secrets.items():
|
||||
response = await client.post(
|
||||
f'{api_url}/api/secrets',
|
||||
json={
|
||||
'name': key,
|
||||
'description': secret.description,
|
||||
'value': secret.secret.get_secret_value(),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = await client.post(
|
||||
f'{api_url}/api/secrets',
|
||||
json={
|
||||
'name': key,
|
||||
'description': secret.description,
|
||||
'value': secret.secret.get_secret_value(),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(f'Successfully created secret: {key}')
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 400:
|
||||
# Only ignore if it's actually a duplicate error
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_msg = error_data.get('message', '')
|
||||
# The API returns: "Secret {secret_name} already exists"
|
||||
if 'already exists' in error_msg:
|
||||
logger.info(
|
||||
f'Secret "{key}" already exists, continuing - ignoring duplicate',
|
||||
extra={'api_url': api_url},
|
||||
)
|
||||
continue
|
||||
except (KeyError, ValueError, TypeError):
|
||||
pass # If we can't parse JSON, fall through to re-raise
|
||||
# Re-raise all other errors (including non-duplicate 400s)
|
||||
logger.error(
|
||||
f'Failed to setup secret "{key}": HTTP {e.response.status_code}',
|
||||
extra={
|
||||
'api_url': api_url,
|
||||
'response_text': e.response.text[:200],
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
@@ -211,7 +211,7 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
|
||||
secure=False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
mock_posthog.identify.assert_called_once()
|
||||
mock_posthog.set.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -278,7 +278,7 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
|
||||
secure=False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
mock_posthog.identify.assert_called_once()
|
||||
mock_posthog.set.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Tests for SaasNestedConversationManager custom secrets handling during resume."""
|
||||
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from server.saas_nested_conversation_manager import SaasNestedConversationManager
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.integrations.provider import CustomSecret
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
class MockHTTPXResponse:
|
||||
"""Mock httpx.Response that behaves realistically."""
|
||||
|
||||
def __init__(self, status_code: int, json_data: dict | None = None):
|
||||
self.status_code = status_code
|
||||
self._json_data = json_data or {}
|
||||
self.text = str(json_data) if json_data else ''
|
||||
|
||||
def json(self):
|
||||
"""Return JSON data."""
|
||||
if self._json_data:
|
||||
return self._json_data
|
||||
raise ValueError('No JSON data')
|
||||
|
||||
def raise_for_status(self):
|
||||
"""Raise an exception for 4xx/5xx status codes."""
|
||||
if self.status_code >= 400:
|
||||
# Create a proper mock response for the exception
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = self.status_code
|
||||
mock_response.json = self.json
|
||||
mock_response.text = self.text
|
||||
|
||||
error = httpx.HTTPStatusError(
|
||||
f"Client error '{self.status_code}' for url 'test'",
|
||||
request=MagicMock(),
|
||||
response=mock_response,
|
||||
)
|
||||
raise error
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saas_manager():
|
||||
"""Create a SaasNestedConversationManager instance for testing."""
|
||||
manager = SaasNestedConversationManager(
|
||||
sio=MagicMock(),
|
||||
config=MagicMock(spec=OpenHandsConfig),
|
||||
server_config=MagicMock(spec=ServerConfig),
|
||||
file_store=MagicMock(spec=InMemoryFileStore),
|
||||
event_retrieval=MagicMock(),
|
||||
)
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_secrets_dont_crash_resume(saas_manager):
|
||||
"""Test that duplicate secrets during resume are handled gracefully."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Simulate resume scenario: secret already exists (400)
|
||||
mock_response = MockHTTPXResponse(
|
||||
400, {'message': 'Secret MY_API_KEY already exists'}
|
||||
)
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
mock_client.post = AsyncMock(side_effect=mock_post)
|
||||
|
||||
custom_secrets = MappingProxyType(
|
||||
{
|
||||
'MY_API_KEY': CustomSecret(
|
||||
secret=SecretStr('api_key_value'),
|
||||
description='API Key that already exists on resume',
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Should not raise despite 400 "already exists" error
|
||||
await saas_manager._setup_custom_secrets(
|
||||
client=mock_client,
|
||||
api_url='https://runtime.example.com',
|
||||
custom_secrets=custom_secrets,
|
||||
)
|
||||
|
||||
assert mock_client.post.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_400_errors_still_fail(saas_manager):
|
||||
"""Test that non-duplicate 400 errors are still raised."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# 400 error but NOT a duplicate
|
||||
mock_response = MockHTTPXResponse(400, {'message': 'Invalid secret name format'})
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
mock_client.post = AsyncMock(side_effect=mock_post)
|
||||
|
||||
custom_secrets = MappingProxyType(
|
||||
{
|
||||
'INVALID!NAME': CustomSecret(
|
||||
secret=SecretStr('value'), description='Secret with invalid name'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as exc_info:
|
||||
await saas_manager._setup_custom_secrets(
|
||||
client=mock_client,
|
||||
api_url='https://runtime.example.com',
|
||||
custom_secrets=custom_secrets,
|
||||
)
|
||||
|
||||
assert exc_info.value.response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_secret_creation_still_works(saas_manager):
|
||||
"""Test that normal secret creation works correctly."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Successful creation
|
||||
mock_response = MockHTTPXResponse(200, {'message': 'Secret created'})
|
||||
|
||||
async def mock_post(*args, **kwargs):
|
||||
return mock_response
|
||||
|
||||
mock_client.post = AsyncMock(side_effect=mock_post)
|
||||
|
||||
custom_secrets = MappingProxyType(
|
||||
{
|
||||
'NEW_SECRET': CustomSecret(
|
||||
secret=SecretStr('new_value'), description='A new secret'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
await saas_manager._setup_custom_secrets(
|
||||
client=mock_client,
|
||||
api_url='https://runtime.example.com',
|
||||
custom_secrets=custom_secrets,
|
||||
)
|
||||
|
||||
assert mock_client.post.call_count == 1
|
||||
call_args = mock_client.post.call_args_list[0]
|
||||
assert call_args[1]['json']['name'] == 'NEW_SECRET'
|
||||
assert call_args[1]['json']['value'] == 'new_value'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_empty_secrets_gracefully(saas_manager):
|
||||
"""Test that empty or missing secrets are handled correctly."""
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Test with None
|
||||
await saas_manager._setup_custom_secrets(
|
||||
client=mock_client, api_url='https://runtime.example.com', custom_secrets=None
|
||||
)
|
||||
assert mock_client.post.call_count == 0
|
||||
|
||||
# Test with empty dict
|
||||
await saas_manager._setup_custom_secrets(
|
||||
client=mock_client,
|
||||
api_url='https://runtime.example.com',
|
||||
custom_secrets=MappingProxyType({}),
|
||||
)
|
||||
assert mock_client.post.call_count == 0
|
||||
+56
-5
@@ -157,8 +157,52 @@ describe("MicroagentManagement", () => {
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-06T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
full_name: "user/gitlab-repo/openhands-config",
|
||||
git_provider: "gitlab",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-07T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
full_name: "org/gitlab-org-repo/openhands-config",
|
||||
git_provider: "gitlab",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-08T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to filter repositories with OpenHands suffixes
|
||||
const getRepositoriesWithOpenHandsSuffix = (
|
||||
repositories: GitRepository[],
|
||||
) => {
|
||||
return repositories.filter(
|
||||
(repo) =>
|
||||
repo.full_name.endsWith("/.openhands") ||
|
||||
repo.full_name.endsWith("/openhands-config"),
|
||||
);
|
||||
};
|
||||
|
||||
// Helper functions for mocking search repositories
|
||||
const mockSearchRepositoriesWithData = (data: GitRepository[]) => {
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
};
|
||||
|
||||
const mockSearchRepositoriesEmpty = () => {
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
};
|
||||
|
||||
const mockMicroagents: RepositoryMicroagent[] = [
|
||||
{
|
||||
name: "test-microagent-1",
|
||||
@@ -265,11 +309,11 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseSearchRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
// Mock the search repositories hook to return repositories with OpenHands suffixes
|
||||
const mockSearchResults =
|
||||
getRepositoriesWithOpenHandsSuffix(mockRepositories);
|
||||
|
||||
mockSearchRepositoriesWithData(mockSearchResults);
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(GitService, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
@@ -594,6 +638,9 @@ describe("MicroagentManagement", () => {
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock empty search results
|
||||
mockSearchRepositoriesEmpty();
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
@@ -782,6 +829,10 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle empty search results", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock empty search results for this test
|
||||
mockSearchRepositoriesEmpty();
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
|
||||
Vendored
+14
@@ -1,4 +1,18 @@
|
||||
interface Window {
|
||||
__APP_MODE__?: "saas" | "oss";
|
||||
__GITHUB_CLIENT_ID__?: string | null;
|
||||
Reo?: {
|
||||
init: (config: { clientID: string }) => void;
|
||||
identify: (identity: {
|
||||
username: string;
|
||||
type: "github" |"email";
|
||||
other_identities?: Array<{
|
||||
username: string;
|
||||
type: "github" | "email";
|
||||
}>;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
company?: string;
|
||||
}) => void;
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -23,7 +23,7 @@ class GitService {
|
||||
*/
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
per_page = 100,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
|
||||
@@ -107,7 +107,7 @@ export function ChatMessage({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-sm w-fit"
|
||||
className="text-sm"
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ChatInputActions({
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -82,6 +82,7 @@ export function CustomChatInput({
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
resetManualResize,
|
||||
} = useGripResize(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
messageToSend,
|
||||
@@ -92,6 +93,7 @@ export function CustomChatInput({
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
onSubmit,
|
||||
resetManualResize,
|
||||
);
|
||||
|
||||
const { handleInput, handlePaste, handleKeyDown, handleBlur, handleFocus } =
|
||||
@@ -113,7 +115,6 @@ export function CustomChatInput({
|
||||
},
|
||||
[setShouldHideSuggestions, clearAllFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{/* Hidden file input */}
|
||||
|
||||
@@ -61,8 +61,11 @@ export function AgentStatus({
|
||||
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
<span className="text-[11px] text-white font-normal leading-5">
|
||||
<div className={cn("flex items-center gap-1 min-w-0", className)}>
|
||||
<span
|
||||
className="text-[11px] text-white font-normal leading-5 flex-1 min-w-0 max-w-full whitespace-normal break-words"
|
||||
title={t(statusCode)}
|
||||
>
|
||||
{t(statusCode)}
|
||||
</span>
|
||||
<div
|
||||
|
||||
+76
-36
@@ -9,7 +9,12 @@ import { GitProviderDropdown } from "#/components/features/home/git-provider-dro
|
||||
import { useMicroagentManagementStore } from "#/state/microagent-management-store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import {
|
||||
cn,
|
||||
shouldIncludeRepository,
|
||||
getOpenHandsQuery,
|
||||
hasOpenHandsSuffix,
|
||||
} from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
@@ -55,6 +60,16 @@ export function MicroagentManagementSidebar({
|
||||
const { data: searchResults, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(debouncedSearchQuery, selectedProvider, false, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
|
||||
|
||||
const {
|
||||
data: userAndOrgLevelRepositorySearchResults,
|
||||
isLoading: isUserAndOrgLevelRepositoryLoading,
|
||||
} = useSearchRepositories(
|
||||
getOpenHandsQuery(selectedProvider),
|
||||
selectedProvider,
|
||||
false,
|
||||
500,
|
||||
);
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
@@ -67,11 +82,27 @@ export function MicroagentManagementSidebar({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Helper function to filter repositories by search query
|
||||
const filterRepositoriesByQuery = (
|
||||
inputRepositories: GitRepository[],
|
||||
query: string,
|
||||
): GitRepository[] => {
|
||||
if (!query.trim()) {
|
||||
return inputRepositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(query);
|
||||
return inputRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
};
|
||||
|
||||
// Filter repositories based on search query and available data
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have search results, use them directly (no filtering needed)
|
||||
// If we have search results, apply client-side filtering for exact matches
|
||||
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
|
||||
return searchResults;
|
||||
return filterRepositoriesByQuery(searchResults, debouncedSearchQuery);
|
||||
}
|
||||
|
||||
// If no search query or no search results, use paginated repositories
|
||||
@@ -80,56 +111,65 @@ export function MicroagentManagementSidebar({
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
// If no search query, return all repositories
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
// Fallback to client-side filtering if search didn't return results
|
||||
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
// Apply filtering to paginated repositories
|
||||
return filterRepositoriesByQuery(allRepositories, debouncedSearchQuery);
|
||||
}, [repositories, debouncedSearchQuery, searchResults]);
|
||||
|
||||
// Process personal and organization repositories from search results
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
if (!userAndOrgLevelRepositorySearchResults?.length) {
|
||||
setPersonalRepositories([]);
|
||||
setOrganizationRepositories([]);
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
|
||||
// Process personal repositories with exact match filtering
|
||||
if (userAndOrgLevelRepositorySearchResults?.length) {
|
||||
userAndOrgLevelRepositorySearchResults.forEach((repo: GitRepository) => {
|
||||
if (
|
||||
hasOpenHandsSuffix(repo, selectedProvider) &&
|
||||
shouldIncludeRepository(repo, debouncedSearchQuery)
|
||||
) {
|
||||
if (repo.owner_type === "user") {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization") {
|
||||
organizationRepos.push(repo);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setPersonalRepositories(personalRepos);
|
||||
setOrganizationRepositories(organizationRepos);
|
||||
}, [
|
||||
userAndOrgLevelRepositorySearchResults,
|
||||
selectedProvider,
|
||||
debouncedSearchQuery,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
]);
|
||||
|
||||
// Process other repositories (non-OpenHands repositories) from filteredRepositories
|
||||
useEffect(() => {
|
||||
if (!filteredRepositories?.length) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
filteredRepositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix =
|
||||
selectedProvider === "gitlab"
|
||||
? repo.full_name.endsWith("/openhands-config")
|
||||
: repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
|
||||
organizationRepos.push(repo);
|
||||
} else {
|
||||
// Only include repositories that don't have the OpenHands suffix
|
||||
if (!hasOpenHandsSuffix(repo, selectedProvider)) {
|
||||
otherRepos.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
setPersonalRepositories(personalRepos);
|
||||
setOrganizationRepositories(organizationRepos);
|
||||
setRepositories(otherRepos);
|
||||
}, [
|
||||
filteredRepositories,
|
||||
selectedProvider,
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
]);
|
||||
}, [filteredRepositories, selectedProvider, setRepositories]);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
@@ -199,7 +239,7 @@ export function MicroagentManagementSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{isLoading || isUserAndOrgLevelRepositoryLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm text-white">
|
||||
|
||||
@@ -12,6 +12,7 @@ export const useChatSubmission = (
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>,
|
||||
smartResize: () => void,
|
||||
onSubmit: (message: string) => void,
|
||||
resetManualResize?: () => void,
|
||||
) => {
|
||||
// Send button click handler
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -30,7 +31,10 @@ export const useChatSubmission = (
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
|
||||
|
||||
// Reset manual resize state for next message
|
||||
resetManualResize?.();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
|
||||
|
||||
// Resume agent button click handler
|
||||
const handleResumeAgent = useCallback(() => {
|
||||
@@ -44,7 +48,10 @@ export const useChatSubmission = (
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit]);
|
||||
|
||||
// Reset manual resize state for next message
|
||||
resetManualResize?.();
|
||||
}, [chatInputRef, fileInputRef, smartResize, onSubmit, resetManualResize]);
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = useCallback((onStop?: () => void) => {
|
||||
|
||||
@@ -58,6 +58,7 @@ export const useGripResize = (
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
resetManualResize,
|
||||
} = useAutoResize(chatInputRef as React.RefObject<HTMLElement | null>, {
|
||||
minHeight: 20,
|
||||
maxHeight: 400,
|
||||
@@ -76,5 +77,6 @@ export const useGripResize = (
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
resetManualResize,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ export function useSearchRepositories(
|
||||
query: string,
|
||||
selectedProvider?: Provider | null,
|
||||
disabled?: boolean,
|
||||
pageSize: number = 3,
|
||||
pageSize: number = 100,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, RefObject } from "react";
|
||||
import { useCallback, useEffect, RefObject, useRef } from "react";
|
||||
import { IMessageToSend } from "#/state/conversation-store";
|
||||
import { EPS } from "#/utils/constants";
|
||||
import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils";
|
||||
import { useDragResize } from "./use-drag-resize";
|
||||
|
||||
// Constants
|
||||
@@ -8,6 +10,13 @@ const DEFAULT_MAX_HEIGHT = 120;
|
||||
const HEIGHT_INCREMENT = 20;
|
||||
const MANUAL_OVERSIZE_THRESHOLD = 50;
|
||||
|
||||
// Manual height tracking utilities
|
||||
const useManualHeight = () => {
|
||||
const hasUserResizedRef = useRef(false);
|
||||
const manualHeightRef = useRef<number | null>(null);
|
||||
return { hasUserResizedRef, manualHeightRef };
|
||||
};
|
||||
|
||||
interface UseAutoResizeOptions {
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
@@ -19,11 +28,11 @@ interface UseAutoResizeOptions {
|
||||
}
|
||||
|
||||
interface UseAutoResizeReturn {
|
||||
autoResize: () => void;
|
||||
smartResize: () => void;
|
||||
handleGripMouseDown: (e: React.MouseEvent) => void;
|
||||
handleGripTouchStart: (e: React.TouchEvent) => void;
|
||||
increaseHeightForEmptyContent: () => void;
|
||||
resetManualResize: () => void;
|
||||
}
|
||||
|
||||
// Height management utilities
|
||||
@@ -60,27 +69,6 @@ const applyHeightToElement = (
|
||||
return finalHeight;
|
||||
};
|
||||
|
||||
const calculateOptimalHeight = (
|
||||
element: HTMLElement,
|
||||
constraints: HeightConstraints,
|
||||
): number => {
|
||||
const { minHeight, maxHeight, scrollHeight } = {
|
||||
...constraints,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
|
||||
if (scrollHeight <= maxHeight) {
|
||||
return Math.max(scrollHeight, minHeight);
|
||||
}
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
const getCurrentElementHeight = (
|
||||
element: HTMLElement,
|
||||
minHeight: number,
|
||||
): number =>
|
||||
element.offsetHeight || parseInt(element.style.height || `${minHeight}`, 10);
|
||||
|
||||
const isManuallyOversized = (
|
||||
currentHeight: number,
|
||||
contentHeight: number,
|
||||
@@ -91,18 +79,16 @@ const measureElementHeights = (
|
||||
element: HTMLElement,
|
||||
minHeight: number,
|
||||
): HeightMeasurements => {
|
||||
const currentHeight = getCurrentElementHeight(element, minHeight);
|
||||
const currentStyleHeight = parseInt(
|
||||
element.style.height || `${minHeight}`,
|
||||
10,
|
||||
);
|
||||
// Use the previous explicit style height as the "current" for restore, not offsetHeight
|
||||
const currentStyleHeight = getStyleHeightPx(element, minHeight);
|
||||
const currentHeight = currentStyleHeight;
|
||||
|
||||
// Temporarily reset to measure content
|
||||
element.style.setProperty("height", "auto");
|
||||
const contentHeight = element.scrollHeight;
|
||||
|
||||
// Restore height
|
||||
element.style.setProperty("height", `${currentStyleHeight}px`);
|
||||
setStyleHeightPx(element, currentStyleHeight);
|
||||
|
||||
return {
|
||||
currentHeight,
|
||||
@@ -111,44 +97,6 @@ const measureElementHeights = (
|
||||
};
|
||||
};
|
||||
|
||||
const determineResizeStrategy = (
|
||||
measurements: HeightMeasurements,
|
||||
minHeight: number,
|
||||
maxHeight: number,
|
||||
): ResizeStrategy => {
|
||||
const { currentHeight, contentHeight } = measurements;
|
||||
|
||||
// If content fits in current height, just manage overflow
|
||||
if (contentHeight <= currentHeight) {
|
||||
return {
|
||||
finalHeight: currentHeight,
|
||||
overflowY: "hidden",
|
||||
};
|
||||
}
|
||||
|
||||
// If content exceeds current height but is within normal auto-resize range
|
||||
if (contentHeight <= maxHeight) {
|
||||
// Only grow if the current height is close to the content height (not manually resized much larger)
|
||||
if (!isManuallyOversized(currentHeight, contentHeight)) {
|
||||
return {
|
||||
finalHeight: Math.max(contentHeight, minHeight),
|
||||
overflowY: "hidden",
|
||||
};
|
||||
}
|
||||
// Keep manual height but show scrollbar since content exceeds visible area
|
||||
return {
|
||||
finalHeight: currentHeight,
|
||||
overflowY: "auto",
|
||||
};
|
||||
}
|
||||
|
||||
// Content exceeds max height
|
||||
return {
|
||||
finalHeight: maxHeight,
|
||||
overflowY: "auto",
|
||||
};
|
||||
};
|
||||
|
||||
const applyResizeStrategy = (
|
||||
element: HTMLElement,
|
||||
strategy: ResizeStrategy,
|
||||
@@ -169,15 +117,12 @@ const executeHeightCallback = (
|
||||
};
|
||||
|
||||
// DOM manipulation utilities
|
||||
const resetElementHeight = (element: HTMLElement): void => {
|
||||
element.style.setProperty("height", "auto");
|
||||
element.style.setProperty("overflow-y", "hidden");
|
||||
};
|
||||
|
||||
export const useAutoResize = (
|
||||
elementRef: RefObject<HTMLElement | null>,
|
||||
options: UseAutoResizeOptions = {},
|
||||
): UseAutoResizeReturn => {
|
||||
const pendingSmartRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
minHeight = DEFAULT_MIN_HEIGHT,
|
||||
maxHeight = DEFAULT_MAX_HEIGHT,
|
||||
@@ -189,65 +134,184 @@ export const useAutoResize = (
|
||||
} = options;
|
||||
|
||||
const constraints: HeightConstraints = { minHeight, maxHeight };
|
||||
const { hasUserResizedRef, manualHeightRef } = useManualHeight();
|
||||
|
||||
const resetManualResize = () => {
|
||||
hasUserResizedRef.current = false;
|
||||
manualHeightRef.current = null;
|
||||
};
|
||||
|
||||
// Wrap onHeightChange to track manual height during drag
|
||||
const handleExternalHeightChange = useCallback(
|
||||
(elementHeight: number) => {
|
||||
onHeightChange?.(elementHeight);
|
||||
if (hasUserResizedRef.current) {
|
||||
manualHeightRef.current = elementHeight;
|
||||
}
|
||||
},
|
||||
[onHeightChange],
|
||||
);
|
||||
|
||||
// Handle drag start - set manual mode flag
|
||||
const handleDragStart = useCallback(() => {
|
||||
hasUserResizedRef.current = true;
|
||||
onGripDragStart?.();
|
||||
}, [onGripDragStart]);
|
||||
|
||||
// Handle drag end - clear manual mode if at minimum height
|
||||
const handleDragEnd = useCallback(() => {
|
||||
const textareaElement = elementRef.current;
|
||||
if (textareaElement) {
|
||||
const currentHeight = getStyleHeightPx(textareaElement, minHeight);
|
||||
if (Math.abs(currentHeight - minHeight) <= EPS) {
|
||||
hasUserResizedRef.current = false;
|
||||
manualHeightRef.current = null;
|
||||
}
|
||||
}
|
||||
onGripDragEnd?.();
|
||||
}, [minHeight, onGripDragEnd]);
|
||||
|
||||
// Use the drag resize hook for manual resizing functionality
|
||||
const { handleGripMouseDown, handleGripTouchStart } = useDragResize({
|
||||
elementRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onGripDragStart: enableManualResize ? onGripDragStart : undefined,
|
||||
onGripDragEnd: enableManualResize ? onGripDragEnd : undefined,
|
||||
onHeightChange,
|
||||
onGripDragStart: enableManualResize ? handleDragStart : undefined,
|
||||
onGripDragEnd: enableManualResize ? handleDragEnd : undefined,
|
||||
onHeightChange: handleExternalHeightChange,
|
||||
});
|
||||
|
||||
// Auto-resize functionality for contenteditable div
|
||||
const autoResize = () => {
|
||||
// Handle content that fits within current height
|
||||
const handleContentFitsInCurrentHeight = useCallback(
|
||||
(
|
||||
element: HTMLElement,
|
||||
currentHeight: number,
|
||||
contentHeight: number,
|
||||
): void => {
|
||||
// If user manually resized and we're above min height, preserve their chosen height
|
||||
if (hasUserResizedRef.current && currentHeight > minHeight) {
|
||||
applyResizeStrategy(element, {
|
||||
finalHeight: currentHeight,
|
||||
overflowY: "hidden",
|
||||
});
|
||||
executeHeightCallback(currentHeight, onHeightChange);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise allow shrinking towards content (respect minHeight)
|
||||
const finalHeight = Math.max(contentHeight, minHeight);
|
||||
applyResizeStrategy(element, {
|
||||
finalHeight,
|
||||
overflowY: "hidden",
|
||||
});
|
||||
executeHeightCallback(finalHeight, onHeightChange);
|
||||
},
|
||||
[minHeight, onHeightChange],
|
||||
);
|
||||
|
||||
// Handle content that exceeds current height but within max height
|
||||
const handleContentExceedsCurrentHeight = useCallback(
|
||||
(
|
||||
element: HTMLElement,
|
||||
currentHeight: number,
|
||||
contentHeight: number,
|
||||
): void => {
|
||||
// Grow unless the element is manually oversized beyond content significantly
|
||||
if (!isManuallyOversized(currentHeight, contentHeight)) {
|
||||
const finalHeight = Math.max(contentHeight, minHeight);
|
||||
applyResizeStrategy(element, {
|
||||
finalHeight,
|
||||
overflowY: "hidden",
|
||||
});
|
||||
executeHeightCallback(finalHeight, onHeightChange);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep manual height and allow scrolling as needed
|
||||
applyResizeStrategy(element, {
|
||||
finalHeight: currentHeight,
|
||||
overflowY: "auto",
|
||||
});
|
||||
executeHeightCallback(currentHeight, onHeightChange);
|
||||
},
|
||||
[minHeight, onHeightChange],
|
||||
);
|
||||
|
||||
// Handle content that exceeds max height
|
||||
const handleContentExceedsMaxHeight = useCallback(
|
||||
(element: HTMLElement) => {
|
||||
applyResizeStrategy(element, {
|
||||
finalHeight: maxHeight,
|
||||
overflowY: "auto",
|
||||
});
|
||||
executeHeightCallback(maxHeight, onHeightChange);
|
||||
},
|
||||
[maxHeight, onHeightChange],
|
||||
);
|
||||
|
||||
// Debounced smartResize body
|
||||
const smartResizeBody = useCallback(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
// Reset height to auto to get the actual content height
|
||||
resetElementHeight(element);
|
||||
const textIsEmpty = (element.textContent ?? "").trim().length === 0;
|
||||
|
||||
// Calculate and apply optimal height
|
||||
const optimalHeight = calculateOptimalHeight(element, constraints);
|
||||
const finalHeight = applyHeightToElement(
|
||||
element,
|
||||
optimalHeight,
|
||||
constraints,
|
||||
);
|
||||
|
||||
// Execute height change callback
|
||||
executeHeightCallback(finalHeight, onHeightChange);
|
||||
};
|
||||
|
||||
// Smart resize that respects manual height
|
||||
const smartResize = useCallback(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
// If empty content and we have a manual height above min, preserve it
|
||||
if (
|
||||
textIsEmpty &&
|
||||
hasUserResizedRef.current &&
|
||||
manualHeightRef.current &&
|
||||
manualHeightRef.current > minHeight + EPS
|
||||
) {
|
||||
setStyleHeightPx(element, manualHeightRef.current);
|
||||
element.style.overflowY = "hidden";
|
||||
executeHeightCallback(manualHeightRef.current, onHeightChange);
|
||||
return;
|
||||
}
|
||||
|
||||
// Measure element heights
|
||||
const measurements = measureElementHeights(element, minHeight);
|
||||
const { currentHeight, contentHeight } = measurements;
|
||||
|
||||
// Determine the best resize strategy
|
||||
const strategy = determineResizeStrategy(
|
||||
measurements,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
);
|
||||
// If content fits within current height
|
||||
if (contentHeight <= currentHeight) {
|
||||
handleContentFitsInCurrentHeight(element, currentHeight, contentHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the resize strategy
|
||||
applyResizeStrategy(element, strategy);
|
||||
// If content exceeds current height but within max
|
||||
if (contentHeight <= maxHeight) {
|
||||
handleContentExceedsCurrentHeight(element, currentHeight, contentHeight);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute height change callback
|
||||
executeHeightCallback(strategy.finalHeight, onHeightChange);
|
||||
}, [elementRef, minHeight, maxHeight, onHeightChange]);
|
||||
// Content exceeds max height
|
||||
handleContentExceedsMaxHeight(element);
|
||||
}, [
|
||||
elementRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeightChange,
|
||||
handleContentFitsInCurrentHeight,
|
||||
handleContentExceedsCurrentHeight,
|
||||
handleContentExceedsMaxHeight,
|
||||
]);
|
||||
|
||||
// rAF-debounced smartResize wrapper to collapse bursts
|
||||
const smartResize = useCallback(() => {
|
||||
if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current);
|
||||
pendingSmartRef.current = requestAnimationFrame(() => {
|
||||
pendingSmartRef.current = null;
|
||||
smartResizeBody();
|
||||
});
|
||||
}, [smartResizeBody]);
|
||||
|
||||
// Function to increase height when content is empty
|
||||
const increaseHeightForEmptyContent = () => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const currentHeight = element.offsetHeight;
|
||||
const currentHeight = getStyleHeightPx(element, minHeight);
|
||||
const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight);
|
||||
|
||||
if (newHeight > currentHeight) {
|
||||
@@ -255,6 +319,10 @@ export const useAutoResize = (
|
||||
|
||||
// Execute height change callback
|
||||
executeHeightCallback(finalHeight, onHeightChange);
|
||||
|
||||
// Set manual mode for Shift+Enter height increases
|
||||
hasUserResizedRef.current = true;
|
||||
manualHeightRef.current = finalHeight;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -273,10 +341,10 @@ export const useAutoResize = (
|
||||
}, [smartResize]);
|
||||
|
||||
return {
|
||||
autoResize,
|
||||
smartResize,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
resetManualResize,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RefObject } from "react";
|
||||
import { EPS } from "#/utils/constants";
|
||||
import { isMobileDevice } from "#/utils/utils";
|
||||
|
||||
// Drag handling hook
|
||||
@@ -9,6 +10,7 @@ interface UseDragResizeOptions {
|
||||
onGripDragStart?: () => void;
|
||||
onGripDragEnd?: () => void;
|
||||
onHeightChange?: (height: number) => void;
|
||||
onReachedMinHeight?: () => void; // Notify when user drags to minimum height
|
||||
}
|
||||
|
||||
export const useDragResize = ({
|
||||
@@ -18,6 +20,7 @@ export const useDragResize = ({
|
||||
onGripDragStart,
|
||||
onGripDragEnd,
|
||||
onHeightChange,
|
||||
onReachedMinHeight,
|
||||
}: UseDragResizeOptions) => {
|
||||
const getClientY = (event: MouseEvent | TouchEvent): number => {
|
||||
if ("touches" in event && event.touches.length > 0) {
|
||||
@@ -56,6 +59,11 @@ export const useDragResize = ({
|
||||
if (onHeightChange) {
|
||||
onHeightChange(newHeight);
|
||||
}
|
||||
|
||||
// Notify when dragged to minimum height to clear manual mode
|
||||
if (onReachedMinHeight && Math.abs(newHeight - minHeight) <= EPS) {
|
||||
onReachedMinHeight();
|
||||
}
|
||||
};
|
||||
return handleDragMove;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { useConfig } from "./query/use-config";
|
||||
import { useGitUser } from "./query/use-git-user";
|
||||
import { getLoginMethod, LoginMethod } from "#/utils/local-storage";
|
||||
import reoService, { ReoIdentity } from "#/utils/reo";
|
||||
|
||||
/**
|
||||
* Maps login method to Reo identity type
|
||||
*/
|
||||
const mapLoginMethodToReoType = (method: LoginMethod): ReoIdentity["type"] => {
|
||||
// Reo is not supporting gitlab and bitbucket.
|
||||
switch (method) {
|
||||
case LoginMethod.GITHUB:
|
||||
return "github";
|
||||
case LoginMethod.ENTERPRISE_SSO:
|
||||
return "email";
|
||||
default:
|
||||
return "email";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates email identity object if email is available
|
||||
*/
|
||||
const buildEmailIdentity = (
|
||||
email?: string | null,
|
||||
): ReoIdentity["other_identities"] => {
|
||||
if (!email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
username: email,
|
||||
type: "email",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses full name into firstname and lastname
|
||||
* Handles cases where name might be empty or only have one part
|
||||
*/
|
||||
const parseNameFields = (
|
||||
fullName?: string | null,
|
||||
): { firstname?: string; lastname?: string } => {
|
||||
if (!fullName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [firstname, ...rest] = fullName.split(" ");
|
||||
if (!firstname) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
firstname,
|
||||
lastname: rest.length > 0 ? rest.join(" ") : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds complete Reo identity from user data and login method
|
||||
*/
|
||||
const buildReoIdentity = (
|
||||
user: {
|
||||
login: string;
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
company?: string | null;
|
||||
},
|
||||
loginMethod: LoginMethod,
|
||||
): ReoIdentity => {
|
||||
const { firstname, lastname } = parseNameFields(user.name);
|
||||
|
||||
return {
|
||||
username: user.login,
|
||||
type: mapLoginMethodToReoType(loginMethod),
|
||||
other_identities: buildEmailIdentity(user.email),
|
||||
firstname,
|
||||
lastname,
|
||||
company: user.company || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to handle Reo.dev tracking integration
|
||||
* Only active in SaaS mode
|
||||
*/
|
||||
export const useReoTracking = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: user } = useGitUser();
|
||||
const [hasIdentified, setHasIdentified] = React.useState(false);
|
||||
|
||||
// Initialize Reo.dev when in SaaS mode
|
||||
React.useEffect(() => {
|
||||
const initReo = async () => {
|
||||
if (config?.APP_MODE === "saas" && !reoService.isInitialized()) {
|
||||
await reoService.init();
|
||||
}
|
||||
};
|
||||
|
||||
initReo();
|
||||
}, [config?.APP_MODE]);
|
||||
|
||||
// Identify user when user data is available and we're in SaaS mode
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
config?.APP_MODE !== "saas" ||
|
||||
!user ||
|
||||
hasIdentified ||
|
||||
!reoService.isInitialized()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loginMethod = getLoginMethod();
|
||||
if (!loginMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build identity payload from user data
|
||||
const identity = buildReoIdentity(user, loginMethod);
|
||||
|
||||
// Identify user in Reo
|
||||
reoService.identify(identity);
|
||||
setHasIdentified(true);
|
||||
}, [config?.APP_MODE, user, hasIdentified]);
|
||||
};
|
||||
@@ -88,13 +88,15 @@ function GitChanges() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
gitChanges.map((change) => (
|
||||
<FileDiffViewer
|
||||
key={change.path}
|
||||
path={change.path}
|
||||
type={change.status}
|
||||
/>
|
||||
))
|
||||
gitChanges
|
||||
.slice(0, 100)
|
||||
.map((change) => (
|
||||
<FileDiffViewer
|
||||
key={change.path}
|
||||
path={change.path}
|
||||
type={change.status}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useAutoLogin } from "#/hooks/use-auto-login";
|
||||
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
||||
import { useReoTracking } from "#/hooks/use-reo-tracking";
|
||||
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
|
||||
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
|
||||
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
|
||||
@@ -96,6 +97,9 @@ export default function MainApp() {
|
||||
// Handle authentication callback and set login method after successful authentication
|
||||
useAuthCallback();
|
||||
|
||||
// Initialize Reo.dev tracking in SaaS mode
|
||||
useReoTracking();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't change language when on TOS page
|
||||
if (!isOnTosPage && settings?.LANGUAGE) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { ActionBase } from "./base";
|
||||
import { TaskItem } from "./common";
|
||||
|
||||
interface MCPToolAction extends ActionBase<"MCPToolAction"> {
|
||||
/**
|
||||
* Dynamic data fields from the tool call
|
||||
*/
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FinishAction extends ActionBase<"FinishAction"> {
|
||||
/**
|
||||
* Final message to send to the user
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ThinkAction extends ActionBase<"ThinkAction"> {
|
||||
/**
|
||||
* The thought to log
|
||||
*/
|
||||
thought: string;
|
||||
}
|
||||
|
||||
interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
|
||||
/**
|
||||
* The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.
|
||||
*/
|
||||
is_input: boolean;
|
||||
/**
|
||||
* Optional. Sets a maximum time limit (in seconds) for running the command. If the command takes longer than this limit, you’ll be asked whether to continue or stop it.
|
||||
*/
|
||||
timeout: number | null;
|
||||
/**
|
||||
* If True, reset the terminal by creating a new session. Used only when the terminal becomes unresponsive. Note that all previously set environment variables and session state will be lost after reset. Cannot be used with is_input=True.
|
||||
*/
|
||||
reset: boolean;
|
||||
}
|
||||
|
||||
interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
|
||||
/**
|
||||
* Absolute path to file or directory.
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Required parameter of `create` command, with the content of the file to be created.
|
||||
*/
|
||||
file_text: string | null;
|
||||
/**
|
||||
* Required parameter of `str_replace` command containing the string in `path` to replace.
|
||||
*/
|
||||
old_str: string | null;
|
||||
/**
|
||||
* Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
|
||||
*/
|
||||
new_str: string | null;
|
||||
/**
|
||||
* Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`. Must be >= 1.
|
||||
*/
|
||||
insert_line: number | null;
|
||||
/**
|
||||
* Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
|
||||
*/
|
||||
view_range: [number, number] | null;
|
||||
}
|
||||
|
||||
interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
|
||||
/**
|
||||
* The command to execute. `view` shows the current task list. `plan` creates or updates the task list based on provided requirements and progress. Always `view` the current list before making changes.
|
||||
*/
|
||||
command: "view" | "plan";
|
||||
/**
|
||||
* The full task list. Required parameter of `plan` command.
|
||||
*/
|
||||
task_list: TaskItem[];
|
||||
}
|
||||
|
||||
interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
|
||||
/**
|
||||
* The URL to navigate to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Whether to open in a new tab. Default: False
|
||||
*/
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserClickAction extends ActionBase<"BrowserClickAction"> {
|
||||
/**
|
||||
* The index of the element to click (from browser_get_state)
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* Whether to open any resulting navigation in a new tab. Default: False
|
||||
*/
|
||||
new_tab: boolean;
|
||||
}
|
||||
|
||||
interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
|
||||
/**
|
||||
* The index of the input element (from browser_get_state)
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* The text to type
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
|
||||
/**
|
||||
* Whether to include a screenshot of the current page. Default: False
|
||||
*/
|
||||
include_screenshot: boolean;
|
||||
}
|
||||
|
||||
interface BrowserGetContentAction
|
||||
extends ActionBase<"BrowserGetContentAction"> {
|
||||
/**
|
||||
* Whether to include links in the content (default: False)
|
||||
*/
|
||||
extract_links: boolean;
|
||||
/**
|
||||
* Character index to start from in the page content (default: 0)
|
||||
*/
|
||||
start_from_char: number;
|
||||
}
|
||||
|
||||
interface BrowserScrollAction extends ActionBase<"BrowserScrollAction"> {
|
||||
/**
|
||||
* Direction to scroll. Options: 'up', 'down'. Default: 'down'
|
||||
*/
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
|
||||
// No additional properties - this action has no parameters
|
||||
}
|
||||
|
||||
interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
|
||||
*/
|
||||
tab_id: string;
|
||||
}
|
||||
|
||||
interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
|
||||
/**
|
||||
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
|
||||
*/
|
||||
tab_id: string;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| MCPToolAction
|
||||
| FinishAction
|
||||
| ThinkAction
|
||||
| ExecuteBashAction
|
||||
| StrReplaceEditorAction
|
||||
| TaskTrackerAction
|
||||
| BrowserNavigateAction
|
||||
| BrowserClickAction
|
||||
| BrowserTypeAction
|
||||
| BrowserGetStateAction
|
||||
| BrowserGetContentAction
|
||||
| BrowserScrollAction
|
||||
| BrowserGoBackAction
|
||||
| BrowserListTabsAction
|
||||
| BrowserSwitchTabAction
|
||||
| BrowserCloseTabAction;
|
||||
@@ -0,0 +1,36 @@
|
||||
type EventType =
|
||||
| "MCPTool"
|
||||
| "Finish"
|
||||
| "Think"
|
||||
| "ExecuteBash"
|
||||
| "StrReplaceEditor"
|
||||
| "TaskTracker";
|
||||
|
||||
type ActionOnlyType =
|
||||
| "BrowserNavigate"
|
||||
| "BrowserClick"
|
||||
| "BrowserType"
|
||||
| "BrowserGetState"
|
||||
| "BrowserGetContent"
|
||||
| "BrowserScroll"
|
||||
| "BrowserGoBack"
|
||||
| "BrowserListTabs"
|
||||
| "BrowserSwitchTab"
|
||||
| "BrowserCloseTab";
|
||||
|
||||
type ObservationOnlyType = "Browser";
|
||||
|
||||
type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
|
||||
type ObservationEventType =
|
||||
| `${ObservationOnlyType}Observation`
|
||||
| `${EventType}Observation`;
|
||||
|
||||
export interface ActionBase<T extends ActionEventType = ActionEventType> {
|
||||
kind: T;
|
||||
}
|
||||
|
||||
export interface ObservationBase<
|
||||
T extends ObservationEventType = ObservationEventType,
|
||||
> {
|
||||
kind: T;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface TaskItem {
|
||||
/**
|
||||
* A brief title for the task.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Additional details or notes about the task.
|
||||
*/
|
||||
notes: string;
|
||||
/**
|
||||
* The current status of the task. One of 'todo', 'in_progress', or 'done'.
|
||||
*/
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}
|
||||
|
||||
export interface CmdOutputMetadata {
|
||||
/**
|
||||
* The exit code of the last executed command
|
||||
*/
|
||||
exit_code: number;
|
||||
/**
|
||||
* The process ID of the last executed command
|
||||
*/
|
||||
pid: number;
|
||||
/**
|
||||
* The username of the current user
|
||||
*/
|
||||
username: string | null;
|
||||
/**
|
||||
* The hostname of the machine
|
||||
*/
|
||||
hostname: string | null;
|
||||
/**
|
||||
* The current working directory
|
||||
*/
|
||||
working_dir: string | null;
|
||||
/**
|
||||
* The path to the current Python interpreter, if any
|
||||
*/
|
||||
py_interpreter_path: string | null;
|
||||
/**
|
||||
* Prefix to add to command output
|
||||
*/
|
||||
prefix: string;
|
||||
/**
|
||||
* Suffix to add to command output
|
||||
*/
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
// Type aliases for event and tool call IDs
|
||||
export type EventID = string;
|
||||
export type ToolCallID = string;
|
||||
|
||||
// Source type for events
|
||||
export type SourceType = "agent" | "user" | "environment";
|
||||
|
||||
// Security risk levels
|
||||
export enum SecurityRisk {
|
||||
UNKNOWN = "UNKNOWN",
|
||||
LOW = "LOW",
|
||||
MEDIUM = "MEDIUM",
|
||||
HIGH = "HIGH",
|
||||
}
|
||||
|
||||
// Content types for LLM messages
|
||||
export interface TextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
cache_prompt?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: "image";
|
||||
image_urls: string[];
|
||||
cache_prompt?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
EventID,
|
||||
SourceType,
|
||||
ToolCallID,
|
||||
TextContent,
|
||||
ImageContent,
|
||||
} from "./common";
|
||||
|
||||
// Base event interface - fundamental properties for all events
|
||||
export interface BaseEvent {
|
||||
/**
|
||||
* Unique event id (ULID/UUID)
|
||||
*/
|
||||
id: EventID;
|
||||
|
||||
/**
|
||||
* Event timestamp (ISO string)
|
||||
*/
|
||||
timestamp: string;
|
||||
|
||||
/**
|
||||
* The source of this event
|
||||
*/
|
||||
source: SourceType;
|
||||
}
|
||||
|
||||
// LLM Message structure
|
||||
export interface Message {
|
||||
role: "user" | "system" | "assistant" | "tool";
|
||||
content: (TextContent | ImageContent)[];
|
||||
cache_enabled?: boolean;
|
||||
vision_enabled?: boolean;
|
||||
tool_calls?: ChatCompletionMessageToolCall[];
|
||||
reasoning_content?: string | null;
|
||||
thinking_blocks?: (ThinkingBlock | RedactedThinkingBlock)[];
|
||||
name?: string;
|
||||
tool_call_id?: ToolCallID;
|
||||
}
|
||||
|
||||
// Tool call structure from LiteLLM
|
||||
export interface ChatCompletionMessageToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Tool parameter structure from LiteLLM
|
||||
export interface ChatCompletionToolParam {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// Thinking blocks for Anthropic extended thinking feature
|
||||
export interface ThinkingBlock {
|
||||
type: "thinking";
|
||||
/**
|
||||
* The thinking content
|
||||
*/
|
||||
thinking: string;
|
||||
/**
|
||||
* Cryptographic signature for the thinking block
|
||||
*/
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface RedactedThinkingBlock {
|
||||
type: "redacted_thinking";
|
||||
/**
|
||||
* The redacted thinking content
|
||||
*/
|
||||
data: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Export all base types
|
||||
export * from "./action";
|
||||
export * from "./base";
|
||||
export * from "./common";
|
||||
export * from "./event";
|
||||
export * from "./observation";
|
||||
@@ -0,0 +1,136 @@
|
||||
import { ObservationBase } from "./base";
|
||||
import {
|
||||
CmdOutputMetadata,
|
||||
TaskItem,
|
||||
TextContent,
|
||||
ImageContent,
|
||||
} from "./common";
|
||||
|
||||
interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
|
||||
/**
|
||||
* Content returned from the MCP tool converted to LLM Ready TextContent or ImageContent
|
||||
*/
|
||||
content: Array<TextContent | ImageContent>;
|
||||
/**
|
||||
* Whether the call resulted in an error
|
||||
*/
|
||||
is_error: boolean;
|
||||
/**
|
||||
* Name of the tool that was called
|
||||
*/
|
||||
tool_name: string;
|
||||
}
|
||||
|
||||
interface FinishObservation extends ObservationBase<"FinishObservation"> {
|
||||
/**
|
||||
* Final message sent to the user
|
||||
*/
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
|
||||
/**
|
||||
* Confirmation message. DEFAULT: "Your thought has been logged."
|
||||
*/
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
|
||||
/**
|
||||
* The output message from the browser operation
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* Error message if any
|
||||
*/
|
||||
error: string | null;
|
||||
/**
|
||||
* Base64 screenshot data if available
|
||||
*/
|
||||
screenshot_data: string | null;
|
||||
}
|
||||
|
||||
interface ExecuteBashObservation
|
||||
extends ObservationBase<"ExecuteBashObservation"> {
|
||||
/**
|
||||
* The raw output from the tool.
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* The bash command that was executed. Can be empty string if the observation is from a previous command that hit soft timeout and is not yet finished.
|
||||
*/
|
||||
command: string | null;
|
||||
/**
|
||||
* The exit code of the command. -1 indicates the process hit the soft timeout and is not yet finished.
|
||||
*/
|
||||
exit_code: number | null;
|
||||
/**
|
||||
* Whether there was an error during command execution.
|
||||
*/
|
||||
error: boolean;
|
||||
/**
|
||||
* Whether the command execution timed out.
|
||||
*/
|
||||
timeout: boolean;
|
||||
/**
|
||||
* Additional metadata captured from PS1 after command execution.
|
||||
*/
|
||||
metadata: CmdOutputMetadata;
|
||||
}
|
||||
|
||||
interface StrReplaceEditorObservation
|
||||
extends ObservationBase<"StrReplaceEditorObservation"> {
|
||||
/**
|
||||
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
|
||||
*/
|
||||
command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
|
||||
/**
|
||||
* The output message from the tool for the LLM to see.
|
||||
*/
|
||||
output: string;
|
||||
/**
|
||||
* The file path that was edited.
|
||||
*/
|
||||
path: string | null;
|
||||
/**
|
||||
* Indicates if the file previously existed. If not, it was created.
|
||||
*/
|
||||
prev_exist: boolean;
|
||||
/**
|
||||
* The content of the file before the edit.
|
||||
*/
|
||||
old_content: string | null;
|
||||
/**
|
||||
* The content of the file after the edit.
|
||||
*/
|
||||
new_content: string | null;
|
||||
/**
|
||||
* Error message if any.
|
||||
*/
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface TaskTrackerObservation
|
||||
extends ObservationBase<"TaskTrackerObservation"> {
|
||||
/**
|
||||
* The formatted task list or status message.
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* The command that was executed.
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* The current task list.
|
||||
*/
|
||||
task_list: TaskItem[];
|
||||
}
|
||||
|
||||
export type Observation =
|
||||
| MCPToolObservation
|
||||
| FinishObservation
|
||||
| ThinkObservation
|
||||
| BrowserObservation
|
||||
| ExecuteBashObservation
|
||||
| StrReplaceEditorObservation
|
||||
| TaskTrackerObservation;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Action } from "../base/action";
|
||||
import { EventID, ToolCallID, SecurityRisk, TextContent } from "../base/common";
|
||||
import {
|
||||
BaseEvent,
|
||||
ChatCompletionMessageToolCall,
|
||||
ThinkingBlock,
|
||||
RedactedThinkingBlock,
|
||||
} from "../base/event";
|
||||
|
||||
export interface ActionEvent extends BaseEvent {
|
||||
/**
|
||||
* The thought process of the agent before taking this action
|
||||
*/
|
||||
thought: TextContent[];
|
||||
|
||||
/**
|
||||
* Intermediate reasoning/thinking content from reasoning models
|
||||
*/
|
||||
reasoning_content?: string | null;
|
||||
|
||||
/**
|
||||
* Anthropic thinking blocks from the LLM response
|
||||
*/
|
||||
thinking_blocks: (ThinkingBlock | RedactedThinkingBlock)[];
|
||||
|
||||
/**
|
||||
* Single action (tool call) returned by LLM
|
||||
*/
|
||||
action: Action;
|
||||
|
||||
/**
|
||||
* The name of the tool being called
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The unique id returned by LLM API for this tool call
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
|
||||
/**
|
||||
* The tool call received from the LLM response. We keep a copy of it
|
||||
* so it is easier to construct it into LLM message.
|
||||
* This could be different from `action`: e.g., `tool_call` may contain
|
||||
* `security_risk` field predicted by LLM when LLM risk analyzer is enabled,
|
||||
* while `action` does not.
|
||||
*/
|
||||
tool_call: ChatCompletionMessageToolCall;
|
||||
|
||||
/**
|
||||
* Groups related actions from same LLM response. This helps in tracking
|
||||
* and managing results of parallel function calling from the same LLM
|
||||
* response.
|
||||
*/
|
||||
llm_response_id: EventID;
|
||||
|
||||
/**
|
||||
* The LLM's assessment of the safety risk of this action
|
||||
*/
|
||||
security_risk: SecurityRisk;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { EventID } from "../base/common";
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Condensation event - indicates conversation history condensation is happening
|
||||
export interface CondensationEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The IDs of the events that are being forgotten (removed from the View given to the LLM)
|
||||
*/
|
||||
forgotten_event_ids: EventID[];
|
||||
|
||||
/**
|
||||
* An optional summary of the events being forgotten
|
||||
*/
|
||||
summary?: string;
|
||||
|
||||
/**
|
||||
* An optional offset to the start of the resulting view indicating where the summary should be inserted
|
||||
*/
|
||||
summary_offset?: number;
|
||||
}
|
||||
|
||||
// Condensation request event - used to request a condensation of conversation history
|
||||
export interface CondensationRequestEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation request events
|
||||
*/
|
||||
source: "environment";
|
||||
}
|
||||
|
||||
// Condensation summary event - represents a summary generated by a condenser
|
||||
export interface CondensationSummaryEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for condensation summary events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The summary text
|
||||
*/
|
||||
summary: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Conversation state update event - contains conversation state updates
|
||||
export interface ConversationStateUpdateEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for conversation state update events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* Unique key for this state update event.
|
||||
* Can be "full_state" for full state snapshots or field names for partial updates.
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* Serialized conversation state updates.
|
||||
* For "full_state" key, this contains the complete conversation state.
|
||||
* For field-specific keys, this contains the updated field value.
|
||||
*/
|
||||
value: unknown;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Export all event types
|
||||
export * from "./action-event";
|
||||
export * from "./condensation-event";
|
||||
export * from "./conversation-state-event";
|
||||
export * from "./message-event";
|
||||
export * from "./observation-event";
|
||||
export * from "./pause-event";
|
||||
export * from "./system-event";
|
||||
@@ -0,0 +1,19 @@
|
||||
import { TextContent } from "../base/common";
|
||||
import { BaseEvent, Message } from "../base/event";
|
||||
|
||||
export interface MessageEvent extends BaseEvent {
|
||||
/**
|
||||
* The exact LLM message for this message event
|
||||
*/
|
||||
llm_message: Message;
|
||||
|
||||
/**
|
||||
* List of activated microagent names
|
||||
*/
|
||||
activated_microagents: string[];
|
||||
|
||||
/**
|
||||
* List of content added by agent context
|
||||
*/
|
||||
extended_content: TextContent[];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EventID, ToolCallID } from "../base/common";
|
||||
import { BaseEvent } from "../base/event";
|
||||
import { Observation } from "../base/observation";
|
||||
|
||||
// Base interface for observation events
|
||||
export interface ObservationBaseEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "environment" for observation events
|
||||
*/
|
||||
source: "environment";
|
||||
|
||||
/**
|
||||
* The tool name that this observation is responding to
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The tool call id that this observation is responding to
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
}
|
||||
|
||||
// Main observation event interface
|
||||
export interface ObservationEvent extends ObservationBaseEvent {
|
||||
/**
|
||||
* The observation (tool call) sent to LLM
|
||||
*/
|
||||
observation: Observation;
|
||||
|
||||
/**
|
||||
* The action id that this observation is responding to
|
||||
*/
|
||||
action_id: EventID;
|
||||
}
|
||||
|
||||
// User rejection observation event
|
||||
export interface UserRejectObservation extends ObservationBaseEvent {
|
||||
/**
|
||||
* Reason for rejecting the action
|
||||
*/
|
||||
rejection_reason: string;
|
||||
|
||||
/**
|
||||
* The action id that this observation is responding to
|
||||
*/
|
||||
action_id: EventID;
|
||||
}
|
||||
|
||||
// Agent error event
|
||||
export interface AgentErrorEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "agent" for agent error events
|
||||
*/
|
||||
source: "agent";
|
||||
|
||||
/**
|
||||
* The tool name that this observation is responding to
|
||||
*/
|
||||
tool_name: string;
|
||||
|
||||
/**
|
||||
* The tool call id that this observation is responding to
|
||||
*/
|
||||
tool_call_id: ToolCallID;
|
||||
|
||||
/**
|
||||
* The error message from the scaffold
|
||||
*/
|
||||
error: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { BaseEvent } from "../base/event";
|
||||
|
||||
// Pause event - indicates that agent execution was paused by user request
|
||||
export interface PauseEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "user" for pause events
|
||||
*/
|
||||
source: "user";
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TextContent } from "../base/common";
|
||||
import { BaseEvent, ChatCompletionToolParam } from "../base/event";
|
||||
|
||||
// System prompt event interface
|
||||
export interface SystemPromptEvent extends BaseEvent {
|
||||
/**
|
||||
* The source is always "agent" for system prompt events
|
||||
*/
|
||||
source: "agent";
|
||||
|
||||
/**
|
||||
* The system prompt text
|
||||
*/
|
||||
system_prompt: TextContent;
|
||||
|
||||
/**
|
||||
* List of tools in OpenAI tool format
|
||||
*/
|
||||
tools: ChatCompletionToolParam[];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Export all core types
|
||||
|
||||
// Base types (primitive types like Action, Observation, common interfaces)
|
||||
export * from "./base";
|
||||
|
||||
// Event types (main events that extend BaseEvent)
|
||||
export * from "./events";
|
||||
|
||||
// Union type for all OpenHands events
|
||||
export * from "./openhands-event";
|
||||
@@ -0,0 +1,34 @@
|
||||
// Import all event types
|
||||
import {
|
||||
ActionEvent,
|
||||
MessageEvent,
|
||||
ObservationEvent,
|
||||
UserRejectObservation,
|
||||
AgentErrorEvent,
|
||||
SystemPromptEvent,
|
||||
CondensationEvent,
|
||||
CondensationRequestEvent,
|
||||
CondensationSummaryEvent,
|
||||
ConversationStateUpdateEvent,
|
||||
PauseEvent,
|
||||
} from "./events/index";
|
||||
|
||||
/**
|
||||
* Union type representing all possible OpenHands events.
|
||||
* This includes all main event types that can occur in the system.
|
||||
*/
|
||||
export type OpenHandsEvent =
|
||||
// Core action and observation events
|
||||
| ActionEvent
|
||||
| MessageEvent
|
||||
| ObservationEvent
|
||||
| UserRejectObservation
|
||||
| AgentErrorEvent
|
||||
| SystemPromptEvent
|
||||
// Conversation management events
|
||||
| CondensationEvent
|
||||
| CondensationRequestEvent
|
||||
| CondensationSummaryEvent
|
||||
| ConversationStateUpdateEvent
|
||||
// Control events
|
||||
| PauseEvent;
|
||||
@@ -67,3 +67,6 @@ export const CONTEXT_MENU_ICON_TEXT_CLASSNAME = "h-[30px]";
|
||||
export const CHAT_INPUT = {
|
||||
HEIGHT_THRESHOLD: 100, // Height in pixels when suggestions should be hidden
|
||||
};
|
||||
|
||||
// UI tolerance constants
|
||||
export const EPS = 1.5; // px tolerance for "near min" height comparisons
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Reo.dev tracking service for SaaS mode
|
||||
* Tracks developer activity and engagement in the product
|
||||
* Using CDN approach for better TypeScript compatibility
|
||||
*/
|
||||
|
||||
export interface ReoIdentity {
|
||||
username: string;
|
||||
type: "github" | "email";
|
||||
other_identities?: Array<{
|
||||
username: string;
|
||||
type: "github" | "email";
|
||||
}>;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
const REO_CLIENT_ID = "6bac7145b4ee6ec";
|
||||
|
||||
class ReoService {
|
||||
private initialized = false;
|
||||
|
||||
private scriptLoaded = false;
|
||||
|
||||
/**
|
||||
* Load and initialize the Reo.dev tracking script from CDN
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the Reo script dynamically from CDN
|
||||
await this.loadScript();
|
||||
|
||||
// Initialize Reo with client ID
|
||||
if (window.Reo) {
|
||||
window.Reo.init({ clientID: REO_CLIENT_ID });
|
||||
this.initialized = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Reo.dev tracking:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Reo.dev script from CDN
|
||||
*/
|
||||
private loadScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.scriptLoaded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `https://static.reo.dev/${REO_CLIENT_ID}/reo.js`;
|
||||
script.defer = true;
|
||||
|
||||
script.onload = () => {
|
||||
this.scriptLoaded = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
reject(new Error("Failed to load Reo.dev script"));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a user in Reo.dev tracking
|
||||
* Should be called after successful login
|
||||
*/
|
||||
identify(identity: ReoIdentity): void {
|
||||
if (!this.initialized) {
|
||||
console.warn("Reo.dev not initialized. Call init() first.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.Reo) {
|
||||
window.Reo.identify(identity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to identify user in Reo.dev:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Reo.dev is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
const reoService = new ReoService();
|
||||
|
||||
export default reoService;
|
||||
@@ -3,11 +3,41 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTaskGroup } from "#/utils/types";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the numeric height value from an element's style property
|
||||
* @param el The HTML element to get the height from
|
||||
* @param fallback The fallback value to return if style height is invalid
|
||||
* @returns The numeric height value in pixels, or the fallback value
|
||||
*
|
||||
* @example
|
||||
* getStyleHeightPx(element, 20) // Returns 20 if element.style.height is "auto" or invalid
|
||||
* getStyleHeightPx(element, 20) // Returns 100 if element.style.height is "100px"
|
||||
*/
|
||||
export const getStyleHeightPx = (el: HTMLElement, fallback: number): number => {
|
||||
const elementHeight = parseFloat(el.style.height || "");
|
||||
return Number.isFinite(elementHeight) ? elementHeight : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the height style property of an element to a specific pixel value
|
||||
* @param el The HTML element to set the height for
|
||||
* @param height The height value in pixels to set
|
||||
*
|
||||
* @example
|
||||
* setStyleHeightPx(element, 100) // Sets element.style.height to "100px"
|
||||
* setStyleHeightPx(textarea, 200) // Sets textarea.style.height to "200px"
|
||||
*/
|
||||
export const setStyleHeightPx = (el: HTMLElement, height: number): void => {
|
||||
el.style.setProperty("height", `${height}px`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if the user is on a mobile device
|
||||
* @returns True if the user is on a mobile device, false otherwise
|
||||
@@ -509,3 +539,50 @@ export const getStatusClassName = (status: string) => {
|
||||
}
|
||||
return "bg-gray-700 text-gray-300";
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to apply client-side filtering based on search query
|
||||
* @param repo The Git repository to check
|
||||
* @param searchQuery The search query string
|
||||
* @returns True if the repository should be included based on the search query
|
||||
*/
|
||||
export const shouldIncludeRepository = (
|
||||
repo: GitRepository,
|
||||
searchQuery: string,
|
||||
): boolean => {
|
||||
if (!searchQuery.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
const sanitizedRepoName = sanitizeQuery(repo.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OpenHands query string based on the provider
|
||||
* @param provider The git provider
|
||||
* @returns The query string for searching OpenHands repositories
|
||||
*/
|
||||
export const getOpenHandsQuery = (provider: Provider | null): string => {
|
||||
if (provider === "gitlab") {
|
||||
return "openhands-config";
|
||||
}
|
||||
return ".openhands";
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a repository has the OpenHands suffix based on the provider
|
||||
* @param repo The Git repository to check
|
||||
* @param provider The git provider
|
||||
* @returns True if the repository has the OpenHands suffix
|
||||
*/
|
||||
export const hasOpenHandsSuffix = (
|
||||
repo: GitRepository,
|
||||
provider: Provider | null,
|
||||
): boolean => {
|
||||
if (provider === "gitlab") {
|
||||
return repo.full_name.endsWith("/openhands-config");
|
||||
}
|
||||
return repo.full_name.endsWith("/.openhands");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
# Note: We keep our custom spec file in version control
|
||||
# *.spec
|
||||
@@ -0,0 +1,46 @@
|
||||
.PHONY: help install install-dev test format clean run
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "OpenHands CLI - Available commands:"
|
||||
@echo " install - Install the package"
|
||||
@echo " install-dev - Install with development dependencies"
|
||||
@echo " test - Run tests"
|
||||
@echo " format - Format code with ruff"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " run - Run the CLI"
|
||||
|
||||
# Install the package
|
||||
install:
|
||||
uv sync
|
||||
|
||||
# Install with development dependencies
|
||||
install-dev:
|
||||
uv sync --group dev
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# Format code
|
||||
format:
|
||||
uv run ruff format openhands_cli/
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf .venv/
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
# Run the CLI
|
||||
run:
|
||||
uv run openhands
|
||||
|
||||
# Install UV if not present
|
||||
install-uv:
|
||||
@if ! command -v uv &> /dev/null; then \
|
||||
echo "Installing UV..."; \
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh; \
|
||||
else \
|
||||
echo "UV is already installed"; \
|
||||
fi
|
||||
@@ -0,0 +1,36 @@
|
||||
# OpenHands V1 CLI
|
||||
|
||||
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [agent-sdk](https://github.com/All-Hands-AI/agent-sdk)).
|
||||
|
||||
The [OpenHands V0 CLI (legacy)](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/cli) is being deprecated.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
- Prerequisites: Python 3.12+, curl
|
||||
- Install uv (package manager):
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# Restart your shell so "uv" is on PATH, or follow the installer hint
|
||||
```
|
||||
|
||||
### Run the CLI locally
|
||||
```bash
|
||||
make install
|
||||
|
||||
# Start the CLI
|
||||
make run
|
||||
# or
|
||||
uv run openhands
|
||||
```
|
||||
|
||||
### Build a standalone executable
|
||||
```bash
|
||||
# Build (installs PyInstaller if needed)
|
||||
./build.sh --install-pyinstaller
|
||||
|
||||
# The binary will be in dist/
|
||||
./dist/openhands # macOS/Linux
|
||||
# dist/openhands.exe # Windows
|
||||
```
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script for OpenHands CLI using PyInstaller.
|
||||
|
||||
This script packages the OpenHands CLI into a standalone executable binary
|
||||
using PyInstaller with the custom spec file.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
|
||||
|
||||
from openhands.sdk import LLM
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
dummy_agent = get_default_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
metadata=get_llm_metadata(model_name='dummy-model', agent_name='openhands'),
|
||||
),
|
||||
working_dir=WORK_DIR,
|
||||
persistence_dir=PERSISTENCE_DIR,
|
||||
cli_mode=True,
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# SECTION: Build Binary
|
||||
# =================================================
|
||||
|
||||
|
||||
def clean_build_directories() -> None:
|
||||
"""Clean up previous build artifacts."""
|
||||
print('🧹 Cleaning up previous build artifacts...')
|
||||
|
||||
build_dirs = ['build', 'dist', '__pycache__']
|
||||
for dir_name in build_dirs:
|
||||
if os.path.exists(dir_name):
|
||||
print(f' Removing {dir_name}/')
|
||||
shutil.rmtree(dir_name)
|
||||
|
||||
# Clean up .pyc files
|
||||
for root, _dirs, files in os.walk('.'):
|
||||
for file in files:
|
||||
if file.endswith('.pyc'):
|
||||
os.remove(os.path.join(root, file))
|
||||
|
||||
print('✅ Cleanup complete!')
|
||||
|
||||
|
||||
def check_pyinstaller() -> bool:
|
||||
"""Check if PyInstaller is available."""
|
||||
try:
|
||||
subprocess.run(
|
||||
['uv', 'run', 'pyinstaller', '--version'], check=True, capture_output=True
|
||||
)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print(
|
||||
'❌ PyInstaller is not available. Use --install-pyinstaller flag or install manually with:'
|
||||
)
|
||||
print(' uv add --dev pyinstaller')
|
||||
return False
|
||||
|
||||
|
||||
def build_executable(
|
||||
spec_file: str = 'openhands.spec',
|
||||
clean: bool = True,
|
||||
) -> bool:
|
||||
"""Build the executable using PyInstaller."""
|
||||
if clean:
|
||||
clean_build_directories()
|
||||
|
||||
# Check if PyInstaller is available (installation is handled by build.sh)
|
||||
if not check_pyinstaller():
|
||||
return False
|
||||
|
||||
print(f'🔨 Building executable using {spec_file}...')
|
||||
|
||||
try:
|
||||
# Run PyInstaller with uv
|
||||
cmd = ['uv', 'run', 'pyinstaller', spec_file, '--clean']
|
||||
|
||||
print(f'Running: {" ".join(cmd)}')
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
|
||||
print('✅ Build completed successfully!')
|
||||
|
||||
# Check if the executable was created
|
||||
dist_dir = Path('dist')
|
||||
if dist_dir.exists():
|
||||
executables = list(dist_dir.glob('*'))
|
||||
if executables:
|
||||
print('📁 Executable(s) created in dist/:')
|
||||
for exe in executables:
|
||||
size = exe.stat().st_size / (1024 * 1024) # Size in MB
|
||||
print(f' - {exe.name} ({size:.1f} MB)')
|
||||
else:
|
||||
print('⚠️ No executables found in dist/ directory')
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'❌ Build failed: {e}')
|
||||
if e.stdout:
|
||||
print('STDOUT:', e.stdout)
|
||||
if e.stderr:
|
||||
print('STDERR:', e.stderr)
|
||||
return False
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Test and profile binary
|
||||
# =================================================
|
||||
|
||||
WELCOME_MARKERS = ['welcome', 'openhands cli', 'type /help', 'available commands', '>']
|
||||
|
||||
|
||||
def _is_welcome(line: str) -> bool:
|
||||
s = line.strip().lower()
|
||||
return any(marker in s for marker in WELCOME_MARKERS)
|
||||
|
||||
|
||||
def test_executable() -> bool:
|
||||
"""Test the built executable, measuring boot time and total test time."""
|
||||
print('🧪 Testing the built executable...')
|
||||
|
||||
spec_path = os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH)
|
||||
|
||||
specs_path = Path(os.path.expanduser(spec_path))
|
||||
if specs_path.exists():
|
||||
print(f'⚠️ Using existing settings at {specs_path}')
|
||||
else:
|
||||
print(f'💾 Creating dummy settings at {specs_path}')
|
||||
specs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
specs_path.write_text(dummy_agent.model_dump_json())
|
||||
|
||||
exe_path = Path('dist/openhands')
|
||||
if not exe_path.exists():
|
||||
exe_path = Path('dist/openhands.exe')
|
||||
if not exe_path.exists():
|
||||
print('❌ Executable not found!')
|
||||
return False
|
||||
|
||||
try:
|
||||
if os.name != 'nt':
|
||||
os.chmod(exe_path, 0o755)
|
||||
|
||||
boot_start = time.time()
|
||||
proc = subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env={**os.environ},
|
||||
)
|
||||
|
||||
# --- Wait for welcome ---
|
||||
deadline = boot_start + 30
|
||||
saw_welcome = False
|
||||
captured = []
|
||||
|
||||
while time.time() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
rlist, _, _ = select.select([proc.stdout], [], [], 0.2)
|
||||
if not rlist:
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if not line:
|
||||
continue
|
||||
captured.append(line)
|
||||
if _is_welcome(line):
|
||||
saw_welcome = True
|
||||
break
|
||||
|
||||
if not saw_welcome:
|
||||
print('❌ Did not detect welcome prompt')
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
boot_end = time.time()
|
||||
print(f'⏱️ Boot to welcome: {boot_end - boot_start:.2f} seconds')
|
||||
|
||||
# --- Run /help then /exit ---
|
||||
if proc.stdin is None:
|
||||
print('❌ stdin unavailable')
|
||||
proc.kill()
|
||||
return False
|
||||
|
||||
proc.stdin.write('/help\n/exit\n')
|
||||
proc.stdin.flush()
|
||||
out, _ = proc.communicate(timeout=60)
|
||||
|
||||
total_end = time.time()
|
||||
full_output = ''.join(captured) + (out or '')
|
||||
|
||||
print(f'⏱️ End-to-end test time: {total_end - boot_start:.2f} seconds')
|
||||
|
||||
if 'available commands' in full_output.lower():
|
||||
print('✅ Executable starts, welcome detected, and /help works')
|
||||
return True
|
||||
else:
|
||||
print('❌ /help output not found')
|
||||
print('Output preview:', full_output[-500:])
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print('❌ Executable test timed out')
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'❌ Error testing executable: {e}')
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Main
|
||||
# =================================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main function."""
|
||||
parser = argparse.ArgumentParser(description='Build OpenHands CLI executable')
|
||||
parser.add_argument(
|
||||
'--spec', default='openhands.spec', help='PyInstaller spec file to use'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-clean', action='store_true', help='Skip cleaning build directories'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-test', action='store_true', help='Skip testing the built executable'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--install-pyinstaller',
|
||||
action='store_true',
|
||||
help='Install PyInstaller using uv before building',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--no-build', action='store_true', help='Skip testing the built executable'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print('🚀 OpenHands CLI Build Script')
|
||||
print('=' * 40)
|
||||
|
||||
# Check if spec file exists
|
||||
if not os.path.exists(args.spec):
|
||||
print(f"❌ Spec file '{args.spec}' not found!")
|
||||
return 1
|
||||
|
||||
# Build the executable
|
||||
if not args.no_build and not build_executable(args.spec, clean=not args.no_clean):
|
||||
return 1
|
||||
|
||||
# Test the executable
|
||||
if not args.no_test:
|
||||
if not test_executable():
|
||||
print('❌ Executable test failed, build process failed')
|
||||
return 1
|
||||
|
||||
print('\n🎉 Build process completed!')
|
||||
print("📁 Check the 'dist/' directory for your executable")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shell script wrapper for building OpenHands CLI executable.
|
||||
#
|
||||
# This script provides a simple interface to build the OpenHands CLI
|
||||
# using PyInstaller with uv package management.
|
||||
#
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🚀 OpenHands CLI Build Script"
|
||||
echo "=============================="
|
||||
|
||||
# Check if uv is available
|
||||
if ! command -v uv &> /dev/null; then
|
||||
echo "❌ uv is required but not found! Please install uv first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse arguments to check for --install-pyinstaller
|
||||
INSTALL_PYINSTALLER=false
|
||||
PYTHON_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--install-pyinstaller)
|
||||
INSTALL_PYINSTALLER=true
|
||||
PYTHON_ARGS+=("$arg")
|
||||
;;
|
||||
*)
|
||||
PYTHON_ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Install PyInstaller if requested
|
||||
if [ "$INSTALL_PYINSTALLER" = true ]; then
|
||||
echo "📦 Installing PyInstaller with uv..."
|
||||
if uv add --dev pyinstaller; then
|
||||
echo "✅ PyInstaller installed successfully with uv!"
|
||||
else
|
||||
echo "❌ Failed to install PyInstaller"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run the Python build script using uv
|
||||
uv run python build.py "${PYTHON_ARGS[@]}"
|
||||
@@ -0,0 +1,68 @@
|
||||
import atexit
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
ENABLE = os.getenv('IMPORT_PROFILING', '0') not in ('', '0', 'false', 'False')
|
||||
OUT = 'dist/import_profiler.csv'
|
||||
THRESHOLD_MS = float(os.getenv('IMPORT_PROFILING_THRESHOLD_MS', '0'))
|
||||
|
||||
if ENABLE:
|
||||
timings = defaultdict(float) # module -> total seconds (first load only)
|
||||
counts = defaultdict(int) # module -> number of first-loads (should be 1)
|
||||
max_dur = defaultdict(float) # module -> max single load seconds
|
||||
|
||||
try:
|
||||
import importlib._bootstrap as _bootstrap # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
_bootstrap = None
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
if _bootstrap is not None:
|
||||
_orig_find_and_load = _bootstrap._find_and_load
|
||||
|
||||
def _timed_find_and_load(name, import_):
|
||||
preloaded = name in sys.modules # cache hit?
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
return _orig_find_and_load(name, import_)
|
||||
finally:
|
||||
if not preloaded:
|
||||
dt = time.perf_counter() - t0
|
||||
timings[name] += dt
|
||||
counts[name] += 1
|
||||
if dt > max_dur[name]:
|
||||
max_dur[name] = dt
|
||||
|
||||
_bootstrap._find_and_load = _timed_find_and_load
|
||||
|
||||
@atexit.register
|
||||
def _dump_import_profile():
|
||||
def ms(s):
|
||||
return f'{s * 1000:.3f}'
|
||||
|
||||
items = [
|
||||
(name, counts[name], timings[name], max_dur[name])
|
||||
for name in timings
|
||||
if timings[name] * 1000 >= THRESHOLD_MS
|
||||
]
|
||||
items.sort(key=lambda x: x[2], reverse=True)
|
||||
try:
|
||||
with open(OUT, 'w', encoding='utf-8') as f:
|
||||
f.write('module,count,total_ms,max_ms\n')
|
||||
for name, cnt, tot_s, max_s in items:
|
||||
f.write(f'{name},{cnt},{ms(tot_s)},{ms(max_s)}\n')
|
||||
# brief summary
|
||||
if items:
|
||||
w = max(len(n) for n, *_ in items[:25])
|
||||
sys.stderr.write('\n=== Import Time Profile (first-load only) ===\n')
|
||||
sys.stderr.write(f'{"module".ljust(w)} count total_ms max_ms\n')
|
||||
for name, cnt, tot_s, max_s in items[:25]:
|
||||
sys.stderr.write(
|
||||
f'{name.ljust(w)} {str(cnt).rjust(5)} {ms(tot_s).rjust(8)} {ms(max_s).rjust(7)}\n'
|
||||
)
|
||||
sys.stderr.write(f'\nImport profile written to: {OUT}\n')
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[import-profiler] failed to write profile: {e}\n')
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for OpenHands CLI.
|
||||
|
||||
This spec file configures PyInstaller to create a standalone executable
|
||||
for the OpenHands CLI application.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
from PyInstaller.utils.hooks import (
|
||||
collect_submodules,
|
||||
collect_data_files,
|
||||
copy_metadata
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Get the project root directory (current working directory when running PyInstaller)
|
||||
project_root = Path.cwd()
|
||||
|
||||
a = Analysis(
|
||||
['openhands_cli/simple_main.py'],
|
||||
pathex=[str(project_root)],
|
||||
binaries=[],
|
||||
datas=[
|
||||
# Include any data files that might be needed
|
||||
# Add more data files here if needed in the future
|
||||
*collect_data_files('tiktoken'),
|
||||
*collect_data_files('tiktoken_ext'),
|
||||
*collect_data_files('litellm'),
|
||||
*collect_data_files('fastmcp'),
|
||||
*collect_data_files('mcp'),
|
||||
# Include Jinja prompt templates required by the agent SDK
|
||||
*collect_data_files('openhands.sdk.agent', includes=['prompts/*.j2']),
|
||||
# Include package metadata for importlib.metadata
|
||||
*copy_metadata('fastmcp'),
|
||||
],
|
||||
hiddenimports=[
|
||||
# Explicitly include modules that might not be detected automatically
|
||||
*collect_submodules('openhands_cli'),
|
||||
*collect_submodules('prompt_toolkit'),
|
||||
# Include OpenHands SDK submodules explicitly to avoid resolution issues
|
||||
*collect_submodules('openhands.sdk'),
|
||||
*collect_submodules('openhands.tools'),
|
||||
*collect_submodules('tiktoken'),
|
||||
*collect_submodules('tiktoken_ext'),
|
||||
*collect_submodules('litellm'),
|
||||
*collect_submodules('fastmcp'),
|
||||
# Include mcp but exclude CLI parts that require typer
|
||||
'mcp.types',
|
||||
'mcp.client',
|
||||
'mcp.server',
|
||||
'mcp.shared',
|
||||
'openhands.tools.execute_bash',
|
||||
'openhands.tools.str_replace_editor',
|
||||
'openhands.tools.task_tracker',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
# runtime_hooks=[str(project_root / "hooks" / "rthook_profile_imports.py")],
|
||||
excludes=[
|
||||
# Exclude unnecessary modules to reduce binary size
|
||||
'tkinter',
|
||||
'matplotlib',
|
||||
'numpy',
|
||||
'scipy',
|
||||
'pandas',
|
||||
'IPython',
|
||||
'jupyter',
|
||||
'notebook',
|
||||
# Exclude mcp CLI parts that cause issues
|
||||
'mcp.cli',
|
||||
'prompt_toolkit.contrib.ssh',
|
||||
'fastmcp.cli',
|
||||
'boto3',
|
||||
'botocore',
|
||||
'posthog',
|
||||
'browser-use',
|
||||
'openhands.tools.browser_use'
|
||||
],
|
||||
noarchive=False,
|
||||
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='openhands',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True, # Strip debug symbols to reduce size
|
||||
upx=True, # Use UPX compression if available
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # CLI application needs console
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon=None, # Add icon path here if you have one
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""OpenHands CLI package."""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Agent chat functionality for OpenHands CLI.
|
||||
Provides a conversation interface with an AI agent using OpenHands patterns.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands.sdk import (
|
||||
Message,
|
||||
TextContent,
|
||||
)
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.setup import MissingAgentSpec, setup_conversation
|
||||
from openhands_cli.tui.settings.mcp_screen import MCPScreen
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.tui.tui import (
|
||||
display_help,
|
||||
display_welcome,
|
||||
)
|
||||
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
||||
from openhands_cli.user_actions.utils import get_session_prompter
|
||||
|
||||
|
||||
def _restore_tty() -> None:
|
||||
"""
|
||||
Ensure terminal modes are reset in case prompt_toolkit cleanup didn't run.
|
||||
- Turn off application cursor keys (DECCKM): ESC[?1l
|
||||
- Turn off bracketed paste: ESC[?2004l
|
||||
"""
|
||||
try:
|
||||
sys.stdout.write('\x1b[?1l\x1b[?2004l')
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _print_exit_hint(conversation_id: str) -> None:
|
||||
"""Print a resume hint with the current conversation ID."""
|
||||
print_formatted_text(
|
||||
HTML(f'<grey>Conversation ID:</grey> <yellow>{conversation_id}</yellow>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<grey>Hint:</grey> run <gold>openhands --resume {conversation_id}</gold> '
|
||||
'to resume this conversation.'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
"""Run the agent chat session using the agent SDK.
|
||||
|
||||
|
||||
Raises:
|
||||
AgentSetupError: If agent setup fails
|
||||
KeyboardInterrupt: If user interrupts the session
|
||||
EOFError: If EOF is encountered
|
||||
"""
|
||||
|
||||
conversation = None
|
||||
settings_screen = SettingsScreen()
|
||||
|
||||
while not conversation:
|
||||
try:
|
||||
conversation = setup_conversation(resume_conversation_id)
|
||||
except MissingAgentSpec:
|
||||
settings_screen.handle_basic_settings(escapable=False)
|
||||
|
||||
display_welcome(conversation.id, bool(resume_conversation_id))
|
||||
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = ConversationRunner(conversation)
|
||||
session = get_session_prompter()
|
||||
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML('<gold>> </gold>'),
|
||||
multiline=False,
|
||||
)
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
|
||||
if command == '/exit':
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation.id)
|
||||
break
|
||||
|
||||
elif command == '/settings':
|
||||
settings_screen = SettingsScreen(conversation)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(conversation.agent)
|
||||
continue
|
||||
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation.id)
|
||||
continue
|
||||
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
continue
|
||||
|
||||
elif command == '/status':
|
||||
print_formatted_text(
|
||||
HTML(f'<grey>Conversation ID: {conversation.id}</grey>')
|
||||
)
|
||||
print_formatted_text(HTML('<grey>Status: Active</grey>'))
|
||||
confirmation_status = (
|
||||
'enabled' if conversation.state.confirmation_mode else 'disabled'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f'<grey>Confirmation mode: {confirmation_status}</grey>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/confirm':
|
||||
runner.toggle_confirmation_mode()
|
||||
new_status = (
|
||||
'enabled' if runner.is_confirmation_mode_enabled else 'disabled'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f'<yellow>Confirmation mode {new_status}</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
if not (
|
||||
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
||||
or conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
print_formatted_text(
|
||||
HTML('<red>No paused conversation to resume...</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
runner.process_message(message)
|
||||
|
||||
print() # Add spacing
|
||||
|
||||
except KeyboardInterrupt:
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation.id)
|
||||
break
|
||||
|
||||
# Clean up terminal state
|
||||
_restore_tty()
|
||||
@@ -0,0 +1,4 @@
|
||||
from openhands_cli.listeners.loading_listener import LoadingContext
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
__all__ = ['PauseListener', 'LoadingContext']
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Loading animation utilities for OpenHands CLI.
|
||||
Provides animated loading screens during agent initialization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def display_initialization_animation(text: str, is_loaded: threading.Event) -> None:
|
||||
"""Display a spinning animation while agent is being initialized.
|
||||
|
||||
Args:
|
||||
text: The text to display alongside the animation
|
||||
is_loaded: Threading event that signals when loading is complete
|
||||
"""
|
||||
ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
i = 0
|
||||
while not is_loaded.is_set():
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(
|
||||
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class LoadingContext:
|
||||
"""Context manager for displaying loading animations in a separate thread."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
"""Initialize the loading context.
|
||||
|
||||
Args:
|
||||
text: The text to display during loading
|
||||
"""
|
||||
self.text = text
|
||||
self.is_loaded = threading.Event()
|
||||
self.loading_thread: threading.Thread | None = None
|
||||
|
||||
def __enter__(self) -> 'LoadingContext':
|
||||
"""Start the loading animation in a separate thread."""
|
||||
self.loading_thread = threading.Thread(
|
||||
target=display_initialization_animation,
|
||||
args=(self.text, self.is_loaded),
|
||||
daemon=True,
|
||||
)
|
||||
self.loading_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Stop the loading animation and clean up the thread."""
|
||||
self.is_loaded.set()
|
||||
if self.loading_thread:
|
||||
self.loading_thread.join(
|
||||
timeout=1.0
|
||||
) # Wait up to 1 second for thread to finish
|
||||
@@ -0,0 +1,83 @@
|
||||
import threading
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.input import Input, create_input
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
from openhands.sdk import BaseConversation
|
||||
|
||||
|
||||
class PauseListener(threading.Thread):
|
||||
"""Background key listener that triggers pause on Ctrl-P.
|
||||
|
||||
Starts and stops around agent run() loops to avoid interfering with user prompts.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_pause: Callable,
|
||||
input_source: Input | None = None, # used to pipe inputs for unit tests
|
||||
):
|
||||
super().__init__(daemon=True)
|
||||
self.on_pause = on_pause
|
||||
self._stop_event = threading.Event()
|
||||
self._pause_event = threading.Event()
|
||||
self._input = input_source or create_input()
|
||||
|
||||
def _detect_pause_key_presses(self) -> bool:
|
||||
pause_detected = False
|
||||
|
||||
for key_press in self._input.read_keys():
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlP
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlC
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlD
|
||||
|
||||
return pause_detected
|
||||
|
||||
def _execute_pause(self) -> None:
|
||||
self._pause_event.set() # Mark pause event occurred
|
||||
print_formatted_text(HTML(''))
|
||||
print_formatted_text(
|
||||
HTML('<gold>Pausing agent once step is completed...</gold>')
|
||||
)
|
||||
try:
|
||||
self.on_pause()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
with self._input.raw_mode():
|
||||
# User hasn't paused and pause listener hasn't been shut down
|
||||
while not (self.is_paused() or self.is_stopped()):
|
||||
if self._detect_pause_key_presses():
|
||||
self._execute_pause()
|
||||
finally:
|
||||
try:
|
||||
self._input.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def is_stopped(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def is_paused(self) -> bool:
|
||||
return self._pause_event.is_set()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pause_listener(
|
||||
conversation: BaseConversation, input_source: Input | None = None
|
||||
) -> Iterator[PauseListener]:
|
||||
"""Ensure PauseListener always starts/stops cleanly."""
|
||||
listener = PauseListener(on_pause=conversation.pause, input_source=input_source)
|
||||
listener.start()
|
||||
try:
|
||||
yield listener
|
||||
finally:
|
||||
listener.stop()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Utility functions for LLM configuration in OpenHands CLI."""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
def get_llm_metadata(
|
||||
model_name: str,
|
||||
llm_type: str,
|
||||
session_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Generate LLM metadata for OpenHands CLI.
|
||||
|
||||
Args:
|
||||
model_name: Name of the LLM model
|
||||
agent_name: Name of the agent (defaults to "openhands")
|
||||
session_id: Optional session identifier
|
||||
user_id: Optional user identifier
|
||||
|
||||
Returns:
|
||||
Dictionary containing metadata for LLM initialization
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
openhands_sdk_version: str = 'n/a'
|
||||
try:
|
||||
import openhands.sdk
|
||||
|
||||
openhands_sdk_version = openhands.sdk.__version__
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
pass
|
||||
|
||||
openhands_tools_version: str = 'n/a'
|
||||
try:
|
||||
import openhands.tools
|
||||
|
||||
openhands_tools_version = openhands.tools.__version__
|
||||
except (ModuleNotFoundError, AttributeError):
|
||||
pass
|
||||
|
||||
metadata = {
|
||||
'trace_version': openhands_sdk_version,
|
||||
'tags': [
|
||||
'app:openhands',
|
||||
f'model:{model_name}',
|
||||
f'type:{llm_type}',
|
||||
f'web_host:{os.environ.get("WEB_HOST", "unspecified")}',
|
||||
f'openhands_sdk_version:{openhands_sdk_version}',
|
||||
f'openhands_tools_version:{openhands_tools_version}',
|
||||
],
|
||||
}
|
||||
if session_id is not None:
|
||||
metadata['session_id'] = session_id
|
||||
if user_id is not None:
|
||||
metadata['trace_user_id'] = user_id
|
||||
return metadata
|
||||
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
|
||||
# Configuration directory for storing agent settings and CLI configuration
|
||||
PERSISTENCE_DIR = os.path.expanduser('~/.openhands')
|
||||
CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, 'conversations')
|
||||
|
||||
# Working directory for agent operations (current directory where CLI is run)
|
||||
WORK_DIR = os.getcwd()
|
||||
|
||||
AGENT_SETTINGS_PATH = 'agent_settings.json'
|
||||
|
||||
# MCP configuration file (relative to PERSISTENCE_DIR)
|
||||
MCP_CONFIG_FILE = 'mcp.json'
|
||||
@@ -0,0 +1,30 @@
|
||||
from prompt_toolkit.styles import Style, merge_styles
|
||||
from prompt_toolkit.styles.base import BaseStyle
|
||||
from prompt_toolkit.styles.defaults import default_ui_style
|
||||
|
||||
# Centralized helper for CLI styles so we can safely merge our custom colors
|
||||
# with prompt_toolkit's default UI style. This preserves completion menu and
|
||||
# fuzzy-match visibility across different terminal themes (e.g., Ubuntu).
|
||||
|
||||
COLOR_GOLD = '#FFD700'
|
||||
COLOR_GREY = '#808080'
|
||||
COLOR_AGENT_BLUE = '#4682B4' # Steel blue - readable on light/dark backgrounds
|
||||
|
||||
|
||||
def get_cli_style() -> BaseStyle:
|
||||
base = default_ui_style()
|
||||
custom = Style.from_dict(
|
||||
{
|
||||
'gold': COLOR_GOLD,
|
||||
'grey': COLOR_GREY,
|
||||
'prompt': f'{COLOR_GOLD} bold',
|
||||
# Ensure good contrast for fuzzy matches on the selected completion row
|
||||
# across terminals/themes (e.g., Ubuntu GNOME, Alacritty, Kitty).
|
||||
# See https://github.com/All-Hands-AI/OpenHands/issues/10330
|
||||
'completion-menu.completion.current fuzzymatch.outside': 'fg:#ffffff bg:#888888',
|
||||
'selected': COLOR_GOLD,
|
||||
'risk-high': '#FF0000 bold', # Red bold for HIGH risk
|
||||
'placeholder': '#888888 italic',
|
||||
}
|
||||
)
|
||||
return merge_styles([base, custom])
|
||||
@@ -0,0 +1,163 @@
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import BaseConversation, Message
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus, ConversationState
|
||||
from openhands.sdk.security.confirmation_policy import (
|
||||
AlwaysConfirm,
|
||||
ConfirmationPolicyBase,
|
||||
ConfirmRisky,
|
||||
NeverConfirm,
|
||||
)
|
||||
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
|
||||
from openhands_cli.user_actions import ask_user_confirmation
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
|
||||
class ConversationRunner:
|
||||
"""Handles the conversation state machine logic cleanly."""
|
||||
|
||||
def __init__(self, conversation: BaseConversation):
|
||||
self.conversation = conversation
|
||||
|
||||
@property
|
||||
def is_confirmation_mode_enabled(self):
|
||||
return self.conversation.confirmation_policy_active
|
||||
|
||||
def toggle_confirmation_mode(self):
|
||||
if self.is_confirmation_mode_enabled:
|
||||
self.set_confirmation_policy(NeverConfirm())
|
||||
else:
|
||||
self.set_confirmation_policy(AlwaysConfirm())
|
||||
|
||||
def set_confirmation_policy(
|
||||
self, confirmation_policy: ConfirmationPolicyBase
|
||||
) -> None:
|
||||
self.conversation.set_confirmation_policy(confirmation_policy)
|
||||
|
||||
def _start_listener(self) -> None:
|
||||
self.listener = PauseListener(on_pause=self.conversation.pause)
|
||||
self.listener.start()
|
||||
|
||||
def _print_run_status(self) -> None:
|
||||
print_formatted_text('')
|
||||
if self.conversation.state.agent_status == AgentExecutionStatus.PAUSED:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<yellow>Resuming paused conversation...</yellow><grey> (Press Ctrl-P to pause)</grey>'
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<yellow>Agent running...</yellow><grey> (Press Ctrl-P to pause)</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
def process_message(self, message: Message | None) -> None:
|
||||
"""Process a user message through the conversation.
|
||||
|
||||
Args:
|
||||
message: The user message to process
|
||||
"""
|
||||
|
||||
self._print_run_status()
|
||||
|
||||
# Send message to conversation
|
||||
if message:
|
||||
self.conversation.send_message(message)
|
||||
|
||||
if self.is_confirmation_mode_enabled:
|
||||
self._run_with_confirmation()
|
||||
else:
|
||||
self._run_without_confirmation()
|
||||
|
||||
def _run_without_confirmation(self) -> None:
|
||||
with pause_listener(self.conversation):
|
||||
self.conversation.run()
|
||||
|
||||
def _run_with_confirmation(self) -> None:
|
||||
# If agent was paused, resume with confirmation request
|
||||
if (
|
||||
self.conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
user_confirmation = self._handle_confirmation_request()
|
||||
if user_confirmation == UserConfirmation.DEFER:
|
||||
return
|
||||
|
||||
while True:
|
||||
with pause_listener(self.conversation) as listener:
|
||||
self.conversation.run()
|
||||
|
||||
if listener.is_paused():
|
||||
break
|
||||
|
||||
# In confirmation mode, agent either finishes or waits for user confirmation
|
||||
if self.conversation.state.agent_status == AgentExecutionStatus.FINISHED:
|
||||
break
|
||||
|
||||
elif (
|
||||
self.conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
user_confirmation = self._handle_confirmation_request()
|
||||
if user_confirmation == UserConfirmation.DEFER:
|
||||
return
|
||||
|
||||
else:
|
||||
raise Exception('Infinite loop')
|
||||
|
||||
def _handle_confirmation_request(self) -> UserConfirmation:
|
||||
"""Handle confirmation request from user.
|
||||
|
||||
Returns:
|
||||
UserConfirmation indicating the user's choice
|
||||
"""
|
||||
|
||||
pending_actions = ConversationState.get_unmatched_actions(
|
||||
self.conversation.state.events
|
||||
)
|
||||
if not pending_actions:
|
||||
return UserConfirmation.ACCEPT
|
||||
|
||||
result = ask_user_confirmation(
|
||||
pending_actions,
|
||||
isinstance(self.conversation.state.confirmation_policy, ConfirmRisky),
|
||||
)
|
||||
decision = result.decision
|
||||
policy_change = result.policy_change
|
||||
|
||||
if decision == UserConfirmation.REJECT:
|
||||
self.conversation.reject_pending_actions(
|
||||
result.reason or 'User rejected the actions'
|
||||
)
|
||||
return decision
|
||||
|
||||
if decision == UserConfirmation.DEFER:
|
||||
self.conversation.pause()
|
||||
return decision
|
||||
|
||||
if isinstance(policy_change, NeverConfirm):
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<yellow>Confirmation mode disabled. Agent will proceed without asking.</yellow>'
|
||||
)
|
||||
)
|
||||
self.set_confirmation_policy(policy_change)
|
||||
return decision
|
||||
|
||||
if isinstance(policy_change, ConfirmRisky):
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<yellow>Security-based confirmation enabled. '
|
||||
'LOW/MEDIUM risk actions will auto-confirm, HIGH risk actions will ask for confirmation.</yellow>'
|
||||
)
|
||||
)
|
||||
self.set_confirmation_policy(policy_change)
|
||||
return decision
|
||||
|
||||
# Accept action without changing existing policies
|
||||
assert decision == UserConfirmation.ACCEPT
|
||||
return decision
|
||||
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
|
||||
from openhands.tools.execute_bash import BashTool
|
||||
from openhands.tools.file_editor import FileEditorTool
|
||||
from openhands.tools.task_tracker import TaskTrackerTool
|
||||
from openhands_cli.listeners import LoadingContext
|
||||
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
|
||||
register_tool('BashTool', BashTool)
|
||||
register_tool('FileEditorTool', FileEditorTool)
|
||||
register_tool('TaskTrackerTool', TaskTrackerTool)
|
||||
|
||||
|
||||
class MissingAgentSpec(Exception):
|
||||
"""Raised when agent specification is not found or invalid."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def setup_conversation(conversation_id: str | None = None) -> BaseConversation:
|
||||
"""
|
||||
Setup the conversation with agent.
|
||||
|
||||
Args:
|
||||
conversation_id: conversation ID to use. If not provided, a random UUID will be generated.
|
||||
|
||||
Raises:
|
||||
MissingAgentSpec: If agent specification is not found or invalid.
|
||||
"""
|
||||
|
||||
# Use provided conversation_id or generate a random one
|
||||
if conversation_id is None:
|
||||
conversation_id = uuid.uuid4()
|
||||
elif isinstance(conversation_id, str):
|
||||
try:
|
||||
conversation_id = uuid.UUID(conversation_id)
|
||||
except ValueError as e:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>"
|
||||
)
|
||||
)
|
||||
raise e
|
||||
|
||||
with LoadingContext('Initializing OpenHands agent...'):
|
||||
agent_store = AgentStore()
|
||||
agent = agent_store.load(session_id=str(conversation_id))
|
||||
if not agent:
|
||||
raise MissingAgentSpec(
|
||||
'Agent specification not found. Please configure your agent settings.'
|
||||
)
|
||||
|
||||
# Create conversation - agent context is now set in AgentStore.load()
|
||||
conversation = Conversation(
|
||||
agent=agent,
|
||||
workspace=Workspace(working_dir=WORK_DIR),
|
||||
# Conversation will add /<conversation_id> to this path
|
||||
persistence_dir=CONVERSATIONS_DIR,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
|
||||
)
|
||||
return conversation
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple main entry point for OpenHands CLI.
|
||||
This is a simplified version that demonstrates the TUI functionality.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import warnings
|
||||
|
||||
debug_env = os.getenv('DEBUG', 'false').lower()
|
||||
if debug_env != '1' and debug_env != 'true':
|
||||
logging.disable(logging.WARNING)
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the OpenHands CLI.
|
||||
|
||||
Raises:
|
||||
ImportError: If agent chat dependencies are missing
|
||||
Exception: On other error conditions
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='OpenHands CLI - Terminal User Interface for OpenHands AI Agent'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--resume',
|
||||
type=str,
|
||||
help='Conversation ID to use for the session. If not provided, a random UUID will be generated.',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Start agent chat
|
||||
run_cli_entry(resume_conversation_id=args.resume)
|
||||
|
||||
except ImportError as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error: Agent chat requires additional dependencies: {e}</red>')
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML('<yellow>Please ensure the agent SDK is properly installed.</yellow>')
|
||||
)
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
except EOFError:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error starting agent chat: {e}</red>'))
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
from openhands_cli.tui.tui import DEFAULT_STYLE
|
||||
|
||||
__all__ = [
|
||||
'DEFAULT_STYLE',
|
||||
]
|
||||
@@ -0,0 +1,217 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
from openhands_cli.locations import MCP_CONFIG_FILE, PERSISTENCE_DIR
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import Agent
|
||||
|
||||
|
||||
class MCPScreen:
|
||||
"""
|
||||
MCP Screen
|
||||
|
||||
1. Display information about setting up MCP
|
||||
2. See existing servers that are setup
|
||||
3. Debug additional servers passed via mcp.json
|
||||
4. Identify servers waiting to sync on session restart
|
||||
"""
|
||||
|
||||
# ---------- server spec handlers ----------
|
||||
|
||||
def _check_server_specs_are_equal(
|
||||
self, first_server_spec, second_server_spec
|
||||
) -> bool:
|
||||
first_stringified_server_spec = json.dumps(first_server_spec, sort_keys=True)
|
||||
second_stringified_server_spec = json.dumps(second_server_spec, sort_keys=True)
|
||||
return first_stringified_server_spec == second_stringified_server_spec
|
||||
|
||||
def _check_mcp_config_status(self) -> dict:
|
||||
"""Check the status of the MCP configuration file and return information about it."""
|
||||
config_path = Path(PERSISTENCE_DIR) / MCP_CONFIG_FILE
|
||||
|
||||
if not config_path.exists():
|
||||
return {
|
||||
'exists': False,
|
||||
'valid': False,
|
||||
'servers': {},
|
||||
'message': f'MCP configuration file not found at ~/.openhands/{MCP_CONFIG_FILE}',
|
||||
}
|
||||
|
||||
try:
|
||||
mcp_config = MCPConfig.from_file(config_path)
|
||||
servers = mcp_config.to_dict().get('mcpServers', {})
|
||||
return {
|
||||
'exists': True,
|
||||
'valid': True,
|
||||
'servers': servers,
|
||||
'message': f'Valid MCP configuration found with {len(servers)} server(s)',
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'exists': True,
|
||||
'valid': False,
|
||||
'servers': {},
|
||||
'message': f'Invalid MCP configuration file: {str(e)}',
|
||||
}
|
||||
|
||||
# ---------- TUI helpers ----------
|
||||
|
||||
def _get_mcp_server_diff(
|
||||
self,
|
||||
current: dict[str, Any],
|
||||
incoming: dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Display a diff-style view:
|
||||
|
||||
- Always show the MCP servers the agent is *currently* configured with
|
||||
- If there are incoming servers (from ~/.openhands/mcp.json),
|
||||
clearly show which ones are NEW (not in current) and which ones are CHANGED
|
||||
(same name but different config). Unchanged servers are not repeated.
|
||||
"""
|
||||
|
||||
print_formatted_text(HTML('<white>Current Agent MCP Servers:</white>'))
|
||||
if current:
|
||||
for name, cfg in current.items():
|
||||
self._render_server_summary(name, cfg, indent=2)
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML(' <yellow>None configured on the current agent.</yellow>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# If no incoming, we're done
|
||||
if not incoming:
|
||||
print_formatted_text(
|
||||
HTML('<grey>No incoming servers detected for next restart.</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
return
|
||||
|
||||
# Compare names and configs
|
||||
current_names = set(current.keys())
|
||||
incoming_names = set(incoming.keys())
|
||||
new_servers = sorted(incoming_names - current_names)
|
||||
|
||||
overriden_servers = []
|
||||
for name in sorted(incoming_names & current_names):
|
||||
if not self._check_server_specs_are_equal(current[name], incoming[name]):
|
||||
overriden_servers.append(name)
|
||||
|
||||
# Display incoming section header
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<white>Incoming Servers on Restart (from ~/.openhands/mcp.json):</white>'
|
||||
)
|
||||
)
|
||||
|
||||
if not new_servers and not overriden_servers:
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
' <grey>All configured servers match the current agent configuration.</grey>'
|
||||
)
|
||||
)
|
||||
print_formatted_text('')
|
||||
return
|
||||
|
||||
if new_servers:
|
||||
print_formatted_text(HTML(' <green>New servers (will be added):</green>'))
|
||||
for name in new_servers:
|
||||
self._render_server_summary(name, incoming[name], indent=4)
|
||||
|
||||
if overriden_servers:
|
||||
print_formatted_text(
|
||||
HTML(' <yellow>Updated servers (configuration will change):</yellow>')
|
||||
)
|
||||
for name in overriden_servers:
|
||||
print_formatted_text(HTML(f' <white>• {name}</white>'))
|
||||
print_formatted_text(HTML(' <grey>Current:</grey>'))
|
||||
self._render_server_summary(None, current[name], indent=8)
|
||||
print_formatted_text(HTML(' <grey>Incoming:</grey>'))
|
||||
self._render_server_summary(None, incoming[name], indent=8)
|
||||
|
||||
print_formatted_text('')
|
||||
|
||||
def _render_server_summary(
|
||||
self, server_name: str | None, server_spec: dict[str, Any], indent: int = 2
|
||||
) -> None:
|
||||
pad = ' ' * indent
|
||||
|
||||
if server_name:
|
||||
print_formatted_text(HTML(f'{pad}<white>• {server_name}</white>'))
|
||||
|
||||
if isinstance(server_spec, dict):
|
||||
if 'command' in server_spec:
|
||||
cmd = server_spec.get('command', '')
|
||||
args = server_spec.get('args', [])
|
||||
args_str = ' '.join(args) if args else ''
|
||||
print_formatted_text(HTML(f'{pad} <grey>Type: Command-based</grey>'))
|
||||
if cmd or args_str:
|
||||
print_formatted_text(
|
||||
HTML(f'{pad} <grey>Command: {cmd} {args_str}</grey>')
|
||||
)
|
||||
elif 'url' in server_spec:
|
||||
url = server_spec.get('url', '')
|
||||
auth = server_spec.get('auth', 'none')
|
||||
print_formatted_text(HTML(f'{pad} <grey>Type: URL-based</grey>'))
|
||||
if url:
|
||||
print_formatted_text(HTML(f'{pad} <grey>URL: {url}</grey>'))
|
||||
print_formatted_text(HTML(f'{pad} <grey>Auth: {auth}</grey>'))
|
||||
|
||||
def _display_information_header(self) -> None:
|
||||
print_formatted_text(
|
||||
HTML('<gold>MCP (Model Context Protocol) Configuration</gold>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<white>To get started:</white>'))
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
' 1. Create the configuration file: <cyan>~/.openhands/mcp.json</cyan>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
' 2. Add your MCP server configurations '
|
||||
'<cyan>https://gofastmcp.com/clients/client#configuration-format</cyan>'
|
||||
)
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(' 3. Restart your OpenHands session to load the new configuration')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
# ---------- status + display entrypoint ----------
|
||||
|
||||
def display_mcp_info(self, existing_agent: Agent) -> None:
|
||||
"""Display comprehensive MCP configuration information."""
|
||||
|
||||
self._display_information_header()
|
||||
|
||||
# Always determine current & incoming first
|
||||
status = self._check_mcp_config_status()
|
||||
incoming_servers = status.get('servers', {}) if status.get('valid') else {}
|
||||
current_servers = existing_agent.mcp_config.get('mcpServers', {})
|
||||
|
||||
# Show file status
|
||||
if not status['exists']:
|
||||
print_formatted_text(
|
||||
HTML('<yellow>Status: Configuration file not found</yellow>')
|
||||
)
|
||||
|
||||
elif not status['valid']:
|
||||
print_formatted_text(HTML(f'<red>Status: {status["message"]}</red>'))
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<white>Please check your configuration file format.</white>')
|
||||
)
|
||||
else:
|
||||
print_formatted_text(HTML(f'<green>Status: {status["message"]}</green>'))
|
||||
|
||||
print_formatted_text('')
|
||||
|
||||
# Always show the agent's current servers
|
||||
# Then show incoming (deduped and changes highlighted)
|
||||
self._get_mcp_server_diff(current_servers, incoming_servers)
|
||||
@@ -0,0 +1,204 @@
|
||||
import os
|
||||
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
||||
from openhands_cli.pt_style import COLOR_GREY
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
SettingsType,
|
||||
choose_llm_model,
|
||||
choose_llm_provider,
|
||||
choose_memory_condensation,
|
||||
prompt_api_key,
|
||||
prompt_base_url,
|
||||
prompt_custom_model,
|
||||
save_settings_confirmation,
|
||||
settings_type_confirmation,
|
||||
)
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.shortcuts import print_container
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
from openhands.sdk import LLM, BaseConversation, LocalFileStore
|
||||
from openhands.sdk.security.confirmation_policy import NeverConfirm
|
||||
from openhands.tools.preset.default import get_default_agent
|
||||
|
||||
|
||||
class SettingsScreen:
|
||||
def __init__(self, conversation: BaseConversation | None = None):
|
||||
self.file_store = LocalFileStore(PERSISTENCE_DIR)
|
||||
self.agent_store = AgentStore()
|
||||
self.conversation = conversation
|
||||
|
||||
def display_settings(self) -> None:
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
assert self.conversation is not None, (
|
||||
'Conversation must be set to display settings.'
|
||||
)
|
||||
|
||||
llm = agent_spec.llm
|
||||
advanced_llm_settings = True if llm.base_url else False
|
||||
|
||||
# Prepare labels and values based on settings
|
||||
labels_and_values = []
|
||||
if not advanced_llm_settings:
|
||||
# Attempt to determine provider, fallback if not directly available
|
||||
provider = llm.model.split('/')[0] if '/' in llm.model else 'Unknown'
|
||||
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(' LLM Provider', str(provider)),
|
||||
(' LLM Model', str(llm.model)),
|
||||
]
|
||||
)
|
||||
else:
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(' Custom Model', llm.model),
|
||||
(' Base URL', llm.base_url),
|
||||
]
|
||||
)
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(' API Key', '********' if llm.api_key else 'Not Set'),
|
||||
(
|
||||
' Confirmation Mode',
|
||||
'Enabled'
|
||||
if not isinstance(
|
||||
self.conversation.state.confirmation_policy, NeverConfirm
|
||||
)
|
||||
else 'Disabled',
|
||||
),
|
||||
(
|
||||
' Memory Condensation',
|
||||
'Enabled' if agent_spec.condenser else 'Disabled',
|
||||
),
|
||||
(
|
||||
' Configuration File',
|
||||
os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# Calculate max widths for alignment
|
||||
# Ensure values are strings for len() calculation
|
||||
str_labels_and_values = [
|
||||
(label, str(value)) for label, value in labels_and_values
|
||||
]
|
||||
max_label_width = (
|
||||
max(len(label) for label, _ in str_labels_and_values)
|
||||
if str_labels_and_values
|
||||
else 0
|
||||
)
|
||||
|
||||
# Construct the summary text with aligned columns
|
||||
settings_lines = [
|
||||
f'{label + ":":<{max_label_width + 1}} {value:<}' # Changed value alignment to left (<)
|
||||
for label, value in str_labels_and_values
|
||||
]
|
||||
settings_text = '\n'.join(settings_lines)
|
||||
|
||||
container = Frame(
|
||||
TextArea(
|
||||
text=settings_text,
|
||||
read_only=True,
|
||||
style=COLOR_GREY,
|
||||
wrap_lines=True,
|
||||
),
|
||||
title='Settings',
|
||||
style=f'fg:{COLOR_GREY}',
|
||||
)
|
||||
|
||||
print_container(container)
|
||||
|
||||
self.configure_settings()
|
||||
|
||||
def configure_settings(self):
|
||||
try:
|
||||
settings_type = settings_type_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
return
|
||||
|
||||
if settings_type == SettingsType.BASIC:
|
||||
self.handle_basic_settings()
|
||||
elif settings_type == SettingsType.ADVANCED:
|
||||
self.handle_advanced_settings()
|
||||
|
||||
def handle_basic_settings(self, escapable=True):
|
||||
step_counter = StepCounter(3)
|
||||
try:
|
||||
provider = choose_llm_provider(step_counter, escapable=escapable)
|
||||
llm_model = choose_llm_model(step_counter, provider, escapable=escapable)
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
provider,
|
||||
self.conversation.state.agent.llm.api_key
|
||||
if self.conversation
|
||||
else None,
|
||||
escapable=escapable,
|
||||
)
|
||||
save_settings_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
|
||||
return
|
||||
|
||||
# Store the collected settings for persistence
|
||||
self._save_llm_settings(f'{provider}/{llm_model}', api_key)
|
||||
|
||||
def handle_advanced_settings(self, escapable=True):
|
||||
"""Handle advanced settings configuration with clean step-by-step flow."""
|
||||
step_counter = StepCounter(4)
|
||||
try:
|
||||
custom_model = prompt_custom_model(step_counter)
|
||||
base_url = prompt_base_url(step_counter)
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
|
||||
self.conversation.agent.llm.api_key if self.conversation else None,
|
||||
escapable=escapable,
|
||||
)
|
||||
memory_condensation = choose_memory_condensation(step_counter)
|
||||
|
||||
# Confirm save
|
||||
save_settings_confirmation()
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
|
||||
return
|
||||
|
||||
# Store the collected settings for persistence
|
||||
self._save_advanced_settings(
|
||||
custom_model, base_url, api_key, memory_condensation
|
||||
)
|
||||
|
||||
def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None:
|
||||
llm = LLM(
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
service_id='agent',
|
||||
metadata=get_llm_metadata(model_name=model, llm_type='agent'),
|
||||
)
|
||||
|
||||
agent = self.agent_store.load()
|
||||
if not agent:
|
||||
agent = get_default_agent(llm=llm, cli_mode=True)
|
||||
|
||||
agent = agent.model_copy(update={'llm': llm})
|
||||
self.agent_store.save(agent)
|
||||
|
||||
def _save_advanced_settings(
|
||||
self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool
|
||||
):
|
||||
self._save_llm_settings(custom_model, api_key, base_url=base_url)
|
||||
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
|
||||
if not memory_condensation:
|
||||
agent_spec.model_copy(update={'condenser': None})
|
||||
|
||||
self.agent_store.save(agent_spec)
|
||||
@@ -0,0 +1,93 @@
|
||||
# openhands_cli/settings/store.py
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
from openhands_cli.llm_utils import get_llm_metadata
|
||||
from openhands_cli.locations import (
|
||||
AGENT_SETTINGS_PATH,
|
||||
MCP_CONFIG_FILE,
|
||||
PERSISTENCE_DIR,
|
||||
WORK_DIR,
|
||||
)
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import Agent, AgentContext, LocalFileStore
|
||||
from openhands.sdk.context.condenser import LLMSummarizingCondenser
|
||||
from openhands.tools.preset.default import get_default_tools
|
||||
|
||||
|
||||
class AgentStore:
|
||||
"""Single source of truth for persisting/retrieving AgentSpec."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.file_store = LocalFileStore(root=PERSISTENCE_DIR)
|
||||
|
||||
def load_mcp_configuration(self) -> dict[str, Any]:
|
||||
try:
|
||||
mcp_config_path = Path(self.file_store.root) / MCP_CONFIG_FILE
|
||||
mcp_config = MCPConfig.from_file(mcp_config_path)
|
||||
return mcp_config.to_dict()['mcpServers']
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def load(self, session_id: str | None = None) -> Agent | None:
|
||||
try:
|
||||
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
|
||||
agent = Agent.model_validate_json(str_spec)
|
||||
|
||||
# Update tools with most recent working directory
|
||||
updated_tools = get_default_tools(enable_browser=False)
|
||||
|
||||
agent_context = AgentContext(
|
||||
system_message_suffix=f'You current working directory is: {WORK_DIR}',
|
||||
)
|
||||
|
||||
additional_mcp_config = self.load_mcp_configuration()
|
||||
mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
|
||||
mcp_config.update(additional_mcp_config)
|
||||
|
||||
# Update LLM metadata with current information
|
||||
agent_llm_metadata = get_llm_metadata(
|
||||
model_name=agent.llm.model, llm_type='agent', session_id=session_id
|
||||
)
|
||||
updated_llm = agent.llm.model_copy(update={'metadata': agent_llm_metadata})
|
||||
|
||||
condenser_updates = {}
|
||||
if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):
|
||||
condenser_updates['llm'] = agent.condenser.llm.model_copy(
|
||||
update={
|
||||
'metadata': get_llm_metadata(
|
||||
model_name=agent.condenser.llm.model,
|
||||
llm_type='condenser',
|
||||
session_id=session_id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
agent = agent.model_copy(
|
||||
update={
|
||||
'llm': updated_llm,
|
||||
'tools': updated_tools,
|
||||
'mcp_config': {'mcpServers': mcp_config} if mcp_config else {},
|
||||
'agent_context': agent_context,
|
||||
'condenser': agent.condenser.model_copy(update=condenser_updates)
|
||||
if agent.condenser
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return agent
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception:
|
||||
print_formatted_text(
|
||||
HTML('\n<red>Agent configuration file is corrupted!</red>')
|
||||
)
|
||||
return None
|
||||
|
||||
def save(self, agent: Agent) -> None:
|
||||
serialized_spec = agent.model_dump_json(context={'expose_secrets': True})
|
||||
self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec)
|
||||
@@ -0,0 +1,101 @@
|
||||
from collections.abc import Generator
|
||||
from uuid import UUID
|
||||
|
||||
from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
||||
from prompt_toolkit.document import Document
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.shortcuts import clear
|
||||
|
||||
from openhands_cli import __version__
|
||||
from openhands_cli.pt_style import get_cli_style
|
||||
|
||||
DEFAULT_STYLE = get_cli_style()
|
||||
|
||||
# Available commands with descriptions
|
||||
COMMANDS = {
|
||||
'/exit': 'Exit the application',
|
||||
'/help': 'Display available commands',
|
||||
'/clear': 'Clear the screen',
|
||||
'/status': 'Display conversation details',
|
||||
'/confirm': 'Toggle confirmation mode on/off',
|
||||
'/resume': 'Resume a paused conversation',
|
||||
'/settings': 'Display and modify current settings',
|
||||
'/mcp': 'View MCP (Model Context Protocol) server configuration',
|
||||
}
|
||||
|
||||
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands with interactive dropdown."""
|
||||
|
||||
def get_completions(
|
||||
self, document: Document, complete_event: CompleteEvent
|
||||
) -> Generator[Completion, None, None]:
|
||||
text = document.text_before_cursor.lstrip()
|
||||
if text.startswith('/'):
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command,
|
||||
start_position=-len(text),
|
||||
display_meta=description,
|
||||
style='bg:ansidarkgray fg:gold',
|
||||
)
|
||||
|
||||
|
||||
def display_banner(conversation_id: str, resume: bool = False) -> None:
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
___ _ _ _
|
||||
/ _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
|
||||
| | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
|
||||
| |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
|
||||
\___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
|
||||
|_|
|
||||
</gold>"""),
|
||||
style=DEFAULT_STYLE,
|
||||
)
|
||||
|
||||
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
|
||||
|
||||
print_formatted_text('')
|
||||
if not resume:
|
||||
print_formatted_text(
|
||||
HTML(f'<grey>Initialized conversation {conversation_id}</grey>')
|
||||
)
|
||||
else:
|
||||
print_formatted_text(
|
||||
HTML(f'<grey>Resumed conversation {conversation_id}</grey>')
|
||||
)
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_help() -> None:
|
||||
"""Display help information about available commands."""
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>🤖 OpenHands CLI Help</gold>'))
|
||||
print_formatted_text(HTML('<grey>Available commands:</grey>'))
|
||||
print_formatted_text('')
|
||||
|
||||
for command, description in COMMANDS.items():
|
||||
print_formatted_text(HTML(f' <white>{command}</white> - {description}'))
|
||||
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<grey>Tips:</grey>'))
|
||||
print_formatted_text(' • Type / and press Tab to see command suggestions')
|
||||
print_formatted_text(' • Use arrow keys to navigate through suggestions')
|
||||
print_formatted_text(' • Press Enter to select a command')
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
def display_welcome(conversation_id: UUID, resume: bool = False) -> None:
|
||||
"""Display welcome message."""
|
||||
clear()
|
||||
display_banner(str(conversation_id), resume)
|
||||
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<green>What do you want to build? <grey>Type /help for help</grey></green>'
|
||||
)
|
||||
)
|
||||
print()
|
||||
@@ -0,0 +1,14 @@
|
||||
class StepCounter:
|
||||
"""Automatically manages step numbering for settings flows."""
|
||||
|
||||
def __init__(self, total_steps: int):
|
||||
self.current_step = 0
|
||||
self.total_steps = total_steps
|
||||
|
||||
def next_step(self, prompt: str) -> str:
|
||||
"""Get the next step prompt with automatic numbering."""
|
||||
self.current_step += 1
|
||||
return f'(Step {self.current_step}/{self.total_steps}) {prompt}'
|
||||
|
||||
def existing_step(self, prompt: str) -> str:
|
||||
return f'(Step {self.current_step}/{self.total_steps}) {prompt}'
|
||||
@@ -0,0 +1,17 @@
|
||||
from openhands_cli.user_actions.agent_action import ask_user_confirmation
|
||||
from openhands_cli.user_actions.exit_session import (
|
||||
exit_session_confirmation,
|
||||
)
|
||||
from openhands_cli.user_actions.settings_action import (
|
||||
choose_llm_provider,
|
||||
settings_type_confirmation,
|
||||
)
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
|
||||
__all__ = [
|
||||
'ask_user_confirmation',
|
||||
'exit_session_confirmation',
|
||||
'UserConfirmation',
|
||||
'settings_type_confirmation',
|
||||
'choose_llm_provider',
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk.security.confirmation_policy import (
|
||||
ConfirmRisky,
|
||||
NeverConfirm,
|
||||
SecurityRisk,
|
||||
)
|
||||
from openhands_cli.user_actions.types import ConfirmationResult, UserConfirmation
|
||||
from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
|
||||
|
||||
|
||||
def ask_user_confirmation(
|
||||
pending_actions: list, using_risk_based_policy: bool = False
|
||||
) -> ConfirmationResult:
|
||||
"""Ask user to confirm pending actions.
|
||||
|
||||
Args:
|
||||
pending_actions: List of pending actions from the agent
|
||||
|
||||
Returns:
|
||||
ConfirmationResult with decision, optional policy_change, and reason
|
||||
"""
|
||||
|
||||
if not pending_actions:
|
||||
return ConfirmationResult(decision=UserConfirmation.ACCEPT)
|
||||
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
f'<yellow>🔍 Agent created {len(pending_actions)} action(s) and is waiting for confirmation:</yellow>'
|
||||
)
|
||||
)
|
||||
|
||||
for i, action in enumerate(pending_actions, 1):
|
||||
tool_name = getattr(action, 'tool_name', '[unknown tool]')
|
||||
action_content = (
|
||||
str(getattr(action, 'action', ''))[:100].replace('\n', ' ')
|
||||
or '[unknown action]'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f'<grey> {i}. {tool_name}: {action_content}...</grey>')
|
||||
)
|
||||
|
||||
question = 'Choose an option:'
|
||||
options = [
|
||||
'Yes, proceed',
|
||||
'No, reject (w/o reason)',
|
||||
'No, reject with reason',
|
||||
"Always proceed (don't ask again)",
|
||||
]
|
||||
|
||||
if not using_risk_based_policy:
|
||||
options.append('Auto-confirm LOW/MEDIUM risk, ask for HIGH risk')
|
||||
|
||||
try:
|
||||
index = cli_confirm(question, options, escapable=True)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print_formatted_text(HTML('\n<red>No input received; pausing agent.</red>'))
|
||||
return ConfirmationResult(decision=UserConfirmation.DEFER)
|
||||
|
||||
if index == 0:
|
||||
return ConfirmationResult(decision=UserConfirmation.ACCEPT)
|
||||
elif index == 1:
|
||||
return ConfirmationResult(decision=UserConfirmation.REJECT)
|
||||
elif index == 2:
|
||||
try:
|
||||
reason_result = cli_text_input(
|
||||
'Please enter your reason for rejecting these actions: '
|
||||
)
|
||||
except Exception:
|
||||
return ConfirmationResult(decision=UserConfirmation.DEFER)
|
||||
|
||||
# Support both string return and (reason, cancelled) tuple for tests
|
||||
cancelled = False
|
||||
if isinstance(reason_result, tuple) and len(reason_result) >= 1:
|
||||
reason = reason_result[0] or ''
|
||||
cancelled = bool(reason_result[1]) if len(reason_result) > 1 else False
|
||||
else:
|
||||
reason = str(reason_result or '').strip()
|
||||
|
||||
if cancelled:
|
||||
return ConfirmationResult(decision=UserConfirmation.DEFER)
|
||||
|
||||
return ConfirmationResult(decision=UserConfirmation.REJECT, reason=reason)
|
||||
elif index == 3:
|
||||
return ConfirmationResult(
|
||||
decision=UserConfirmation.ACCEPT, policy_change=NeverConfirm()
|
||||
)
|
||||
elif index == 4:
|
||||
return ConfirmationResult(
|
||||
decision=UserConfirmation.ACCEPT,
|
||||
policy_change=ConfirmRisky(threshold=SecurityRisk.HIGH),
|
||||
)
|
||||
|
||||
return ConfirmationResult(decision=UserConfirmation.REJECT)
|
||||
@@ -0,0 +1,18 @@
|
||||
from openhands_cli.user_actions.types import UserConfirmation
|
||||
from openhands_cli.user_actions.utils import cli_confirm
|
||||
|
||||
|
||||
def exit_session_confirmation() -> UserConfirmation:
|
||||
"""
|
||||
Ask user to confirm exiting session.
|
||||
"""
|
||||
|
||||
question = 'Terminate session?'
|
||||
options = ['Yes, proceed', 'No, dismiss']
|
||||
index = cli_confirm(question, options) # Blocking UI, not escapable
|
||||
|
||||
options_mapping = {
|
||||
0: UserConfirmation.ACCEPT, # User accepts termination session
|
||||
1: UserConfirmation.REJECT, # User does not terminate session
|
||||
}
|
||||
return options_mapping.get(index, UserConfirmation.REJECT)
|
||||
@@ -0,0 +1,159 @@
|
||||
from enum import Enum
|
||||
|
||||
from prompt_toolkit.completion import FuzzyWordCompleter
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.sdk.llm import UNVERIFIED_MODELS_EXCLUDING_BEDROCK, VERIFIED_MODELS
|
||||
from openhands_cli.tui.utils import StepCounter
|
||||
from openhands_cli.user_actions.utils import (
|
||||
NonEmptyValueValidator,
|
||||
cli_confirm,
|
||||
cli_text_input,
|
||||
)
|
||||
|
||||
|
||||
class SettingsType(Enum):
|
||||
BASIC = 'basic'
|
||||
ADVANCED = 'advanced'
|
||||
|
||||
|
||||
def settings_type_confirmation() -> SettingsType:
|
||||
question = 'Which settings would you like to modify?'
|
||||
choices = [
|
||||
'LLM (Basic)',
|
||||
'LLM (Advanced)',
|
||||
'Go back',
|
||||
]
|
||||
|
||||
index = cli_confirm(question, choices, escapable=True)
|
||||
|
||||
if choices[index] == 'Go back':
|
||||
raise KeyboardInterrupt
|
||||
|
||||
options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED}
|
||||
|
||||
return options_map.get(index)
|
||||
|
||||
|
||||
def choose_llm_provider(step_counter: StepCounter, escapable=True) -> str:
|
||||
question = step_counter.next_step(
|
||||
'Select LLM Provider (TAB for options, CTRL-c to cancel): '
|
||||
)
|
||||
options = (
|
||||
list(VERIFIED_MODELS.keys()).copy()
|
||||
+ list(UNVERIFIED_MODELS_EXCLUDING_BEDROCK.keys()).copy()
|
||||
)
|
||||
alternate_option = 'Select another provider'
|
||||
|
||||
display_options = options[:4] + [alternate_option]
|
||||
|
||||
index = cli_confirm(question, display_options, escapable=escapable)
|
||||
chosen_option = display_options[index]
|
||||
if display_options[index] != alternate_option:
|
||||
return chosen_option
|
||||
|
||||
question = step_counter.existing_step(
|
||||
'Type LLM Provider (TAB to complete, CTRL-c to cancel): '
|
||||
)
|
||||
return cli_text_input(
|
||||
question, escapable=True, completer=FuzzyWordCompleter(options, WORD=True)
|
||||
)
|
||||
|
||||
|
||||
def choose_llm_model(step_counter: StepCounter, provider: str, escapable=True) -> str:
|
||||
"""Choose LLM model using spec-driven approach. Return (model, deferred)."""
|
||||
|
||||
models = VERIFIED_MODELS.get(
|
||||
provider, []
|
||||
) + UNVERIFIED_MODELS_EXCLUDING_BEDROCK.get(provider, [])
|
||||
|
||||
if provider == 'openhands':
|
||||
question = (
|
||||
step_counter.next_step('Select Available OpenHands Model:\n')
|
||||
+ 'LLM usage is billed at the providers’ rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms'
|
||||
)
|
||||
else:
|
||||
question = step_counter.next_step(
|
||||
'Select LLM Model (TAB for options, CTRL-c to cancel): '
|
||||
)
|
||||
alternate_option = 'Select another model'
|
||||
display_options = models[:4] + [alternate_option]
|
||||
index = cli_confirm(question, display_options, escapable=escapable)
|
||||
chosen_option = display_options[index]
|
||||
|
||||
if chosen_option != alternate_option:
|
||||
return chosen_option
|
||||
|
||||
question = step_counter.existing_step(
|
||||
'Type model id (TAB to complete, CTRL-c to cancel): '
|
||||
)
|
||||
|
||||
return cli_text_input(
|
||||
question, escapable=True, completer=FuzzyWordCompleter(models, WORD=True)
|
||||
)
|
||||
|
||||
|
||||
def prompt_api_key(
|
||||
step_counter: StepCounter,
|
||||
provider: str,
|
||||
existing_api_key: SecretStr | None = None,
|
||||
escapable=True,
|
||||
) -> str:
|
||||
helper_text = (
|
||||
'\nYou can find your OpenHands LLM API Key in the API Keys tab of OpenHands Cloud: '
|
||||
'https://app.all-hands.dev/settings/api-keys\n'
|
||||
if provider == 'openhands'
|
||||
else ''
|
||||
)
|
||||
|
||||
if existing_api_key:
|
||||
masked_key = existing_api_key.get_secret_value()[:3] + '***'
|
||||
question = f'Enter API Key [{masked_key}] (CTRL-c to cancel, ENTER to keep current, type new to change): '
|
||||
# For existing keys, allow empty input to keep current key
|
||||
validator = None
|
||||
else:
|
||||
question = 'Enter API Key (CTRL-c to cancel): '
|
||||
# For new keys, require non-empty input
|
||||
validator = NonEmptyValueValidator()
|
||||
|
||||
question = helper_text + step_counter.next_step(question)
|
||||
return cli_text_input(
|
||||
question, escapable=escapable, validator=validator, is_password=True
|
||||
)
|
||||
|
||||
|
||||
# Advanced settings functions
|
||||
def prompt_custom_model(step_counter: StepCounter, escapable=True) -> str:
|
||||
"""Prompt for custom model name."""
|
||||
question = step_counter.next_step('Custom Model (CTRL-c to cancel): ')
|
||||
return cli_text_input(question, escapable=escapable)
|
||||
|
||||
|
||||
def prompt_base_url(step_counter: StepCounter, escapable=True) -> str:
|
||||
"""Prompt for base URL."""
|
||||
question = step_counter.next_step('Base URL (CTRL-c to cancel): ')
|
||||
return cli_text_input(
|
||||
question, escapable=escapable, validator=NonEmptyValueValidator()
|
||||
)
|
||||
|
||||
|
||||
def choose_memory_condensation(step_counter: StepCounter, escapable=True) -> bool:
|
||||
"""Choose memory condensation setting."""
|
||||
question = step_counter.next_step('Memory Condensation (CTRL-c to cancel): ')
|
||||
choices = ['Enable', 'Disable']
|
||||
|
||||
index = cli_confirm(question, choices, escapable=escapable)
|
||||
return index == 0 # True for Enable, False for Disable
|
||||
|
||||
|
||||
def save_settings_confirmation() -> bool:
|
||||
"""Prompt user to confirm saving settings."""
|
||||
question = 'Save new settings? (They will take effect after restart)'
|
||||
discard = 'No, discard'
|
||||
options = ['Yes, save', discard]
|
||||
|
||||
index = cli_confirm(question, options, escapable=True)
|
||||
if options[index] == discard:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
return options[index]
|
||||
@@ -0,0 +1,18 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
|
||||
|
||||
|
||||
class UserConfirmation(Enum):
|
||||
ACCEPT = 'accept'
|
||||
REJECT = 'reject'
|
||||
DEFER = 'defer'
|
||||
|
||||
|
||||
class ConfirmationResult(BaseModel):
|
||||
decision: UserConfirmation
|
||||
policy_change: Optional[ConfirmationPolicyBase] = None
|
||||
reason: str = ''
|
||||
@@ -0,0 +1,199 @@
|
||||
from prompt_toolkit import HTML, PromptSession
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import Completer
|
||||
from prompt_toolkit.input.base import Input
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
from prompt_toolkit.layout.layout import Layout
|
||||
from prompt_toolkit.output.base import Output
|
||||
from prompt_toolkit.shortcuts import prompt
|
||||
from prompt_toolkit.validation import ValidationError, Validator
|
||||
|
||||
from openhands_cli.tui import DEFAULT_STYLE
|
||||
from openhands_cli.tui.tui import CommandCompleter
|
||||
|
||||
|
||||
def build_keybindings(
|
||||
choices: list[str], selected: list[int], escapable: bool
|
||||
) -> KeyBindings:
|
||||
"""Create keybindings for the confirm UI. Split for testability."""
|
||||
kb = KeyBindings()
|
||||
|
||||
@kb.add('up')
|
||||
def _handle_up(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] - 1) % len(choices)
|
||||
|
||||
@kb.add('down')
|
||||
def _handle_down(event: KeyPressEvent) -> None:
|
||||
selected[0] = (selected[0] + 1) % len(choices)
|
||||
|
||||
@kb.add('enter')
|
||||
def _handle_enter(event: KeyPressEvent) -> None:
|
||||
event.app.exit(result=selected[0])
|
||||
|
||||
if escapable:
|
||||
|
||||
@kb.add('c-c') # Ctrl+C
|
||||
def _handle_hard_interrupt(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add('c-p') # Ctrl+P
|
||||
def _handle_pause_interrupt(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add('escape') # Escape key
|
||||
def _handle_escape(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
return kb
|
||||
|
||||
|
||||
def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout:
|
||||
"""Create the layout for the confirm UI. Split for testability."""
|
||||
|
||||
def get_choice_text() -> list[tuple[str, str]]:
|
||||
lines: list[tuple[str, str]] = []
|
||||
lines.append(('class:question', f'{question}\n\n'))
|
||||
for i, choice in enumerate(choices):
|
||||
is_selected = i == selected_ref[0]
|
||||
prefix = '> ' if is_selected else ' '
|
||||
style = 'class:selected' if is_selected else 'class:unselected'
|
||||
lines.append((style, f'{prefix}{choice}\n'))
|
||||
return lines
|
||||
|
||||
content_window = Window(
|
||||
FormattedTextControl(get_choice_text),
|
||||
always_hide_cursor=True,
|
||||
height=Dimension(max=8),
|
||||
)
|
||||
return Layout(HSplit([content_window]))
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
question: str = 'Are you sure?',
|
||||
choices: list[str] | None = None,
|
||||
initial_selection: int = 0,
|
||||
escapable: bool = False,
|
||||
input: Input | None = None, # strictly for unit testing
|
||||
output: Output | None = None, # strictly for unit testing
|
||||
) -> int:
|
||||
"""Display a confirmation prompt with the given question and choices.
|
||||
|
||||
Returns the index of the selected choice.
|
||||
"""
|
||||
if choices is None:
|
||||
choices = ['Yes', 'No']
|
||||
selected = [initial_selection] # Using list to allow modification in closure
|
||||
|
||||
kb = build_keybindings(choices, selected, escapable)
|
||||
layout = build_layout(question, choices, selected)
|
||||
|
||||
app = Application(
|
||||
layout=layout,
|
||||
key_bindings=kb,
|
||||
style=DEFAULT_STYLE,
|
||||
full_screen=False,
|
||||
input=input,
|
||||
output=output,
|
||||
)
|
||||
|
||||
return int(app.run(in_thread=True))
|
||||
|
||||
|
||||
def cli_text_input(
|
||||
question: str,
|
||||
escapable: bool = True,
|
||||
completer: Completer | None = None,
|
||||
validator: Validator = None,
|
||||
is_password: bool = False,
|
||||
) -> str:
|
||||
"""Prompt user to enter text input with optional validation.
|
||||
|
||||
Args:
|
||||
question: The prompt question to display
|
||||
escapable: Whether the user can escape with Ctrl+C or Ctrl+P
|
||||
completer: Optional completer for tab completion
|
||||
validator: Optional callable that takes a string and returns True if valid.
|
||||
If validation fails, the callable should display error messages
|
||||
and the user will be reprompted.
|
||||
|
||||
Returns:
|
||||
The validated user input string (stripped of whitespace)
|
||||
"""
|
||||
|
||||
kb = KeyBindings()
|
||||
|
||||
if escapable:
|
||||
|
||||
@kb.add('c-c')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add('c-p')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
@kb.add('enter')
|
||||
def _handle_enter(event: KeyPressEvent):
|
||||
event.app.exit(result=event.current_buffer.text)
|
||||
|
||||
reason = str(
|
||||
prompt(
|
||||
question,
|
||||
style=DEFAULT_STYLE,
|
||||
key_bindings=kb,
|
||||
completer=completer,
|
||||
is_password=is_password,
|
||||
validator=validator,
|
||||
)
|
||||
)
|
||||
return reason.strip()
|
||||
|
||||
|
||||
def get_session_prompter(
|
||||
input: Input | None = None, # strictly for unit testing
|
||||
output: Output | None = None, # strictly for unit testing
|
||||
) -> PromptSession:
|
||||
bindings = KeyBindings()
|
||||
|
||||
@bindings.add('\\', 'enter')
|
||||
def _(event: KeyPressEvent) -> None:
|
||||
# Typing '\' + Enter forces a newline regardless
|
||||
event.current_buffer.insert_text('\n')
|
||||
|
||||
@bindings.add('enter')
|
||||
def _handle_enter(event: KeyPressEvent):
|
||||
event.app.exit(result=event.current_buffer.text)
|
||||
|
||||
@bindings.add('c-c')
|
||||
def _keyboard_interrupt(event: KeyPressEvent):
|
||||
event.app.exit(exception=KeyboardInterrupt())
|
||||
|
||||
session = PromptSession(
|
||||
completer=CommandCompleter(),
|
||||
key_bindings=bindings,
|
||||
prompt_continuation=lambda width, line_number, is_soft_wrap: '...',
|
||||
multiline=True,
|
||||
input=input,
|
||||
output=output,
|
||||
style=DEFAULT_STYLE,
|
||||
placeholder=HTML(
|
||||
'<placeholder>'
|
||||
'Type your message… (tip: press <b>\\</b> + <b>Enter</b> to insert a newline)'
|
||||
'</placeholder>'
|
||||
),
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
|
||||
class NonEmptyValueValidator(Validator):
|
||||
def validate(self, document):
|
||||
text = document.text
|
||||
if not text:
|
||||
raise ValidationError(
|
||||
message='API key cannot be empty. Please enter a valid API key.'
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user