Compare commits

..

90 Commits

Author SHA1 Message Date
amanape
c4d9da03f8 Import get_subscription_access outside block 2025-09-26 17:12:46 +04:00
sp.wack
ef12adc107 fix(backend): Add validation for LLM settings to prevent non-pro user bypass (#11113) 2025-09-26 16:10:09 +04:00
Hiep Le
8a7a5cce5e refactor(frontend): remove store.ts file (#11119)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-26 10:03:18 +07:00
mamoodi
b883fe37e6 fix MCP links (#11137) 2025-09-25 23:32:19 +00:00
mamoodi
182b7adcab Clean up MCP docs and move it to settings (#11133) 2025-09-25 18:28:29 -04:00
mamoodi
63829d0f45 Fix help links for OpenHands Provider (#11128)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-25 19:04:48 +00:00
jpelletier1
830a9e027f docs: Add Pro Subscription documentation page (#11115)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-25 13:07:30 -04:00
mamoodi
120a5d6ebd Add new settings folder for better organization (#11118) 2025-09-25 12:06:10 -04:00
mamoodi
6b1d1869f3 Run pypi workflow on tags (#11112) 2025-09-25 14:03:01 +00:00
Hiep Le
e376c2bfd1 refactor(frontend): migration of agent-slice.ts to zustand (#11102) 2025-09-25 12:44:21 +07:00
mamoodi
f8f74858da Update parts of the OpenHands Cloud docs (#11107) 2025-09-24 16:27:52 -04:00
Ray Myers
848a884b04 chore - Track Python test coverage (#11072) 2025-09-24 15:27:34 -05:00
Hiep Le
88a58a1748 refactor(frontend): migration of jupyter-slice.ts to zustand (#11019) 2025-09-25 00:56:55 +07:00
Hiep Le
f59ea69b70 refactor(frontend): migration of microagent-management-slice.ts to zustand (#11033) 2025-09-24 23:58:17 +07:00
dependabot[bot]
8f004a1f6d chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.86.0 to 5.90.1 in /frontend in the eslint group (#11101)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 20:53:20 +04:00
sp.wack
15b4690ebf feat(frontend): Animate conversation panels (#11099) 2025-09-24 16:41:19 +00:00
Hiep Le
df1c5bbf85 refactor(frontend): migration of event-message-slice.ts to zustand (#11080) 2025-09-24 22:53:45 +07:00
Hiep Le
8adbb76bd7 refactor(frontend): migration of browser-slice.ts to zustand (#11081) 2025-09-24 22:52:48 +07:00
Hiep Le
0095672439 feat(frontend): keyboard shortcuts for file copy and paste (#11096) 2025-09-24 22:52:03 +07:00
Hiep Le
6a5d09660d fix(frontend): upgrade banner covers conversation panel in settings (#11094) 2025-09-24 21:40:16 +07:00
Hiep Le
a94906e15c refactor(frontend): migration of security-analyzer-slice.ts to zustand (#11082) 2025-09-24 21:32:25 +07:00
Hiep Le
12dc256b5a refactor(frontend): git actions should be enabled at all times (#11063) 2025-09-24 21:31:51 +07:00
Hiep Le
11edf33b97 refactor(frontend): remove file-state-slice.ts file (#11061) 2025-09-24 21:31:11 +07:00
Hiep Le
fce66e94e7 refactor(frontend): event message (#11001) 2025-09-24 21:30:55 +07:00
Hiep Le
5457392eae refactor(frontend): migration of conversation-slice.ts to zustand (#11032) 2025-09-24 21:30:37 +07:00
Ray Myers
1e7024b60a fix - Set claude sonnet output limit (#11098) 2025-09-24 13:31:20 +00:00
Hiep Le
3977d4fdd7 fix(frontend): insufficient spacing between last message and chat input (#11055) 2025-09-24 20:13:21 +07:00
Tejas Goyal
16004426a2 feat: Add configurable timeouts for MCP tool invocations (Good first issues: #10684) (#11029)
Co-authored-by: Tejas Goyal <tejas@Tejass-MacBook-Pro.local>
2025-09-24 16:43:54 +04:00
dependabot[bot]
73eb53a379 chore(deps): bump the version-all group across 1 directory with 21 updates (#11078)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 16:24:27 +04:00
BenYao21
d3d70fcc60 issue #9388, this will fix the issue (#10450)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-09-22 16:56:53 -04:00
Xinyi He
7906eab6b1 Add inference generation of SWE-Perf Benchmark (#10246)
Co-authored-by: mamoodi <mamoodiha@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 20:35:30 +00:00
juanmichelini
547e1049f1 Multi swe gym (#10605)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 15:56:26 -04:00
mamoodi
818cc60b52 New label for not going stale (#11069) 2025-09-22 11:53:47 -04:00
Robert Brennan
431d2c1f43 security: upgrade setuptools to >=78.1.1 to address CVE-2025-47273 and CVE-2024-6345 (#11038)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: enyst <engel.nyst@gmail.com>
2025-09-22 04:05:45 +00:00
Engel Nyst
07f23641a3 build(deps): pin litellm to avoid build failure (#11054)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-22 03:54:37 +02:00
Hiep Le
de84af5586 feat(frontend): display lock icon when confirmation mode is enabled (#11030) 2025-09-20 10:55:19 +07:00
Hiep Le
b7765ba3f7 refactor(frontend): fix typecheck (#11037) 2025-09-19 13:43:00 -04:00
Hiep Le
b89f2e51e4 refactor(frontend): migration of metrics-slice.ts to zustand (#11018) 2025-09-19 23:52:21 +07:00
mamoodi
e09f93aa75 Release 0.57.0 (#10981)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-09-19 12:40:56 -04:00
Hiep Le
9f529b105a refactor(frontend): migration of command-slice.ts to zustand (#11003) 2025-09-19 23:33:59 +07:00
Graham Neubig
89e3d2a867 Improve OpenHands provider pricing documentation (#10974)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-20 00:22:44 +08:00
Hiep Le
a7b9a4f291 refactor(frontend): migration of status-slice.ts to zustand (#11017) 2025-09-19 22:27:55 +07:00
Hiep Le
88cd16ae21 refactor(frontend): migration of initial-query-slice.ts to zustand (#11020) 2025-09-19 22:27:20 +07:00
Hiep Le
a8a3e9e604 refactor(frontend): remove the code-slice.ts file (#11021) 2025-09-19 21:22:29 +07:00
Hiep Le
0061bcc0b0 refactor(frontend): custom chat input (#10984) 2025-09-19 21:06:18 +07:00
Hiep Le
9c9fa780b0 refactor(frontend): task tracking observation content (#11002) 2025-09-19 20:03:05 +07:00
Alona
569ac16163 Improve token refresh error logging (#11026) 2025-09-19 14:18:38 +07:00
Robert Brennan
46f7738f41 Update Python packages to latest versions (#11023)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 19:52:46 +00:00
Rohit Malhotra
3f3669dd34 Hotfix: rm model choice override (#11022) 2025-09-18 14:40:06 -04:00
sp.wack
cd65645eea Hide Tavily search API key help text in SaaS mode (#11014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 16:40:29 +00:00
Robert Brennan
8e88a7a277 fix: resolve critical and high CVEs in enterprise Docker image (#10987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-18 11:25:33 -04:00
Hiep Le
b393d52439 refactor(frontend): conversation main (#10985) 2025-09-18 20:23:13 +07:00
Hiep Le
faeec48365 refactor(frontend): conversation card (#10986) 2025-09-18 20:22:59 +07:00
sp.wack
774caf0607 feat: refactor status indicators (#10983)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 22:32:55 +04:00
sp.wack
7222730df0 Fix SaaS callback URLs and pro pill positioning (#10998)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 16:56:02 +00:00
Hiep Le
910177fc57 refactor(frontend): system message modal (#10969) 2025-09-17 21:56:14 +07:00
Hiep Le
ac9badbd20 refactor(frontend): metrics modal (#10968) 2025-09-17 21:55:25 +07:00
Ray Myers
02c299d88f Fix Slack resolver failing on AWAITING_USER_INPUT state (#10992)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-17 09:20:12 -05:00
mamoodi
f65fbef649 Remove runtime settings (#10996) 2025-09-17 13:59:29 +00:00
Hiep Le
3c2acad28d refactor(frontend): microagents modal (#10970) 2025-09-16 22:32:23 +07:00
Boxuan Li
0f1780728e Update str_replace_editor tool to use dynamic workspace path from SANDBOX_VOLUMES (#10965)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 17:46:54 -07:00
sp.wack
d3f3378a4c feat: Upgrade banner for unsubscribed SaaS users (#10890)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 23:04:44 +00:00
Engel Nyst
65f4164749 [Docs] Add environment variables reference table (#10926)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-15 18:31:44 +00:00
Hiep Le
3f984d878b refactor(frontend): move conversation APIs to a dedicated service handler (#10957) 2025-09-16 00:57:15 +07:00
Eliot Jones
10b871f4ab feat: Add Cygnal integration (#10898) 2025-09-15 09:57:03 -04:00
Hiep Le
d664f516db refactor(frontend): conversation tab content component (#10956) 2025-09-15 20:56:38 +07:00
Hiep Le
e74bbd81d1 fix(frontend): suppressing event display in the absence of user messages (#10955) 2025-09-15 20:56:16 +07:00
Hiep Le
ab893f93f0 refactor(frontend): use-auto-resize hook (#10959) 2025-09-15 20:49:15 +07:00
Hiep Le
5aba498e77 refactor(frontend): move billing APIs to a dedicated service handler (#10958) 2025-09-15 20:37:07 +07:00
Hiep Le
1523555eea refactor(frontend): remove dead code (#10839) 2025-09-15 20:35:56 +07:00
Kaushik Ashodiya
30604c40fc fix: improve CLI help and version command performance (#10908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-12 14:23:01 -04:00
Hiep Le
8dc46b7206 refactor(frontend): optimize pre-commit lint script (#10870)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-09-12 15:23:29 +00:00
Hiep Le
69498bebb4 refactor(frontend): new conversation component (#10937)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-12 22:15:26 +07:00
tksrmz
77ee9e25d9 fix(frontend): highlight preceding stars on hover in LikertScale (#10948) 2025-09-12 18:01:40 +04:00
Hiep Le
74753036bb refactor(frontend): move user APIs to a dedicated service handler (#10943) 2025-09-12 09:08:15 +07:00
Hiep Le
95d7c10608 refactor(frontend): move option APIs to a dedicated service handler (#10933) 2025-09-12 00:43:15 +07:00
Hiep Le
c142cc27ff refactor(frontend): home header component (#10930) 2025-09-12 00:10:58 +07:00
Hiep Le
0e20fc206b refactor(frontend): move settings APIs to a dedicated service handler (#10941) 2025-09-11 23:39:23 +07:00
Hiep Le
e21475a88e feat(frontend): persist drawer open/close state on page refresh (#10935)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-09-11 15:58:00 +00:00
Hiep Le
921fec0019 refactor(frontend): expand repository pill to full available width (#10936) 2025-09-11 22:37:44 +07:00
Hiep Le
049f839a62 refactor(frontend): move auth APIs to a dedicated service handler (#10932) 2025-09-11 22:31:41 +07:00
Hiep Le
0dde758e13 refactor(frontend): move microagent management API to a dedicated service handler (#10934) 2025-09-11 22:27:56 +07:00
Tim O'Farrell
8257ae70cc Additional logs to debug container working directories (#10902)
Co-authored-by: Chuck Butkus <chuck@all-hands.dev>
2025-09-11 11:06:19 -04:00
Ray Myers
4513bcc622 chore - MyPy check Enterprise with OpenHands (#10858)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-09-11 11:05:50 -04:00
Hiep Le
b5b9a3f40b refactor(frontend): create waiting for runtime component (#10931) 2025-09-11 21:30:05 +07:00
Xingyao Wang
8ea1259943 Add GitHub workflow for MDX format checking and fix parsing error (#10924)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 23:04:54 +00:00
Ray Myers
ddb2794adf fix - Tag enterprise with the same SHA as app image. (#10921) 2025-09-10 16:47:31 -05:00
sp.wack
79fdcad7ef Fix status indicator and chat input synchronization issue (#10914)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 20:39:14 +00:00
chuckbutkus
1de70b8ce4 Fix runtime init (#10909) 2025-09-10 19:28:12 +00:00
sp.wack
3baeecb27c meta(frontend): Improve UX (#9845)
Co-authored-by: Mislav Lukach <mislavlukach@gmail.com>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-10 18:12:52 +00:00
124 changed files with 1344 additions and 10944 deletions

View File

@@ -1,58 +0,0 @@
# 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"

View File

@@ -37,7 +37,7 @@ jobs:
npm run make-i18n && tsc
npm run check-translation-completeness
# Run lint on the python code (excluding CLI and enterprise)
# Run lint on the python code
lint-python:
name: Lint python
runs-on: blacksmith-4vcpu-ubuntu-2204
@@ -73,24 +73,6 @@ 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==3.7.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

View File

@@ -19,12 +19,16 @@ jobs:
# Run python tests on Linux
test-on-linux:
name: Python Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
strategy:
matrix:
python-version: ["3.12"]
permissions:
# For coverage comment and python-coverage-comment-action branch
pull-requests: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -48,10 +52,21 @@ jobs:
- name: Build Environment
run: make build
- name: Run Unit Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv ./tests/unit
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-openhands
path: |
.coverage.${{ matrix.python_version }}
.coverage.runtime.${{ matrix.python_version }}
include-hidden-files: true
# Run specific Windows python tests
test-on-windows:
name: Python Tests on Windows
@@ -85,7 +100,7 @@ jobs:
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
@@ -102,35 +117,37 @@ jobs:
working-directory: ./enterprise
run: poetry install --with dev,test
- name: Run Unit Tests
working-directory: ./enterprise
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -svv -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./tests/unit
# Use base working directory for coverage paths to line up.
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ 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]
# Run CLI unit tests
test-cli-python:
name: CLI Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
pull-requests: write
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: actions/download-artifact@v5
id: download
with:
fetch-depth: 0
pattern: coverage-*
merge-multiple: true
- name: Set up Python
uses: useblacksmith/setup-python@v6
- name: Coverage comment
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3
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 --group dev
- name: Run CLI unit tests
working-directory: ./openhands-cli
run: |
uv run pytest -v
GITHUB_TOKEN: ${{ github.token }}
MERGE_COVERAGE_FILES: true

View File

@@ -1,7 +1,7 @@
# Publishes the OpenHands PyPi package
name: Publish PyPi Package
# Triggered manually
on:
workflow_dispatch:
inputs:
@@ -9,6 +9,9 @@ on:
description: 'Reason for manual trigger'
required: true
default: ''
push:
tags:
- "*"
jobs:
release:

3
.gitignore vendored
View File

@@ -31,8 +31,7 @@ requirements.txt
# 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: openhands-cli.spec is intentionally tracked for CLI builds
# *.spec
*.spec
# Installer logs
pip-log.txt

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
- 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/|openhands-cli/)
exclude: ^(third_party/|enterprise/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(third_party/|enterprise/|openhands-cli/)
exclude: ^(third_party/|enterprise/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0

View File

@@ -31,6 +31,7 @@
"group": "OpenHands Cloud",
"pages": [
"usage/cloud/openhands-cloud",
"usage/cloud/pro-subscription",
{
"group": "Integrations",
"pages": [
@@ -109,8 +110,7 @@
},
"usage/configuration-options",
"usage/how-to/custom-sandbox-guide",
"usage/search-engine-setup",
"usage/mcp"
"usage/search-engine-setup"
]
}
]
@@ -118,7 +118,13 @@
{
"group": "Customizations & Settings",
"pages": [
"usage/common-settings",
{
"group": "OpenHands Settings",
"pages": [
"usage/settings/secrets-settings",
"usage/settings/mcp-settings"
]
},
"usage/prompting/repository",
{
"group": "Microagents",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

View File

@@ -8,9 +8,21 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow
OpenHands to access your repositories:
### Core App IP
```
@@ -31,17 +43,6 @@ If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist t
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -12,13 +12,10 @@ For the available API endpoints, refer to the
To use the OpenHands Cloud API, you'll need to generate an API key:
1. Log in to your [OpenHands Cloud](https://app.all-hands.dev) account.
2. Navigate to the [Settings page](https://app.all-hands.dev/settings).
3. Select the `API Keys` tab.
4. Click `Create API Key`.
5. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
6. Copy the generated API key and store it securely. It will only be shown once.
![API Key Generation](/static/img/api-key-generation.png)
2. Navigate to the [Settings > API Keys](https://app.all-hands.dev/settings/api-keys) page.
3. Click `Create 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

View File

@@ -8,24 +8,39 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- Launch an empty conversation using `New Conversation`.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.
- See your `Recent Conversations`.
## Settings
The Settings page allows you to:
Settings are divided across tabs, with each tab focusing on a specific area of configuration.
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- Set application settings like your preferred language, notifications and other preferences.
- Add credits to your account.
- [Generate custom secrets](/usage/common-settings#secrets-management).
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- Change your email address.
- `User`
- Change your email address.
- `Integrations`
- [Configure GitHub repository access](/usage/cloud/github-installation#modifying-repository-access) for OpenHands.
- [Install the OpenHands Slack app](/usage/cloud/slack-installation).
- `Application`
- Set your preferred language, notifications and other preferences.
- Toggle task suggestions on GitHub.
- Toggle Solvability Analysis.
- Set a maximum budget per conversation.
- Configure the username and email that OpenHands uses for commits.
- `LLM` (Available for `Pro Users`)
- Choose to use another LLM or use different models from the OpenHands provider.
- `Billing`
- Add credits for using the OpenHands provider.
- Cancel your `Pro` subscription.
- `Secrets`
- [Generate custom secrets](/usage/settings/secrets-settings).
- `API Keys`
- [Create API keys to work with OpenHands programmatically](/usage/cloud/cloud-api).
- `MCP`
- [Setup an MCP server](/usage/settings/mcp-settings)
## Key Features

View File

@@ -12,7 +12,7 @@ description: This guide walks you through the process of installing OpenHands Cl
You can grant OpenHands access to specific GitHub repositories:
1. Click on `Add GitHub repos` on the landing page.
1. Click on `+ Add GitHub Repos` in the repository selection dropdown.
2. Select your organization and choose the specific repositories to grant OpenHands access to.
<Accordion title="OpenHands permissions">
- OpenHands requests short-lived tokens (8-hour expiration) with these permissions:
@@ -34,20 +34,22 @@ You can grant OpenHands access to specific GitHub repositories:
## Modifying Repository Access
You can modify GitHub repository access at any time by:
- Selecting `Add GitHub repos` on the landing page or
- Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Integrations` tab
- Selecting `+ Add GitHub Repos` in the repository selection dropdown or
- Visiting the `Settings > Integrations` page and selecting `Configure GitHub Repositories`
## Working With GitHub Repos in Openhands Cloud
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the `select a repo`
and `select a branch` dropdowns to select the appropriate repository and branch you'd like OpenHands to work on. Then
click on `Launch` to start the conversation!
Once you've granted GitHub repository access, you can start working with your GitHub repository. Use the
`Open Repository` section to select the appropriate repository and branch you'd like OpenHands to work on. Then click
on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo.png)
## Working on Github Issues and Pull Requests Using Openhands
## Working on GitHub Issues and Pull Requests Using Openhands
Giving GitHub repository access to OpenHands also allows you to work on GitHub issues and pull requests directly.
To allow OpenHands to work directly from GitHub directly, you must
[give OpenHands access to your repository](/usage/cloud/github-installation#modifying-repository-access). Once access is
given, you can use OpenHands by labeling the issue or by tagging `@openhands`.
### Working with Issues
@@ -64,7 +66,12 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
<Note>
The `@openhands` mention functionality in pull requests only works if the pull request is both
*to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate
permissions to access both repositories.
</Note>
## Next Steps

View File

@@ -14,16 +14,17 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
## Working With GitLab Repos in Openhands Cloud
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
After signing in with a Gitlab account, use the `Open Repository` section to select the appropriate repository and
branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
![Connect Repo](/static/img/connect-repo.png)
## Using Tokens with Reduced Scopes
OpenHands requests an API-scoped token during OAuth authentication. By default, this token is provided to the agent.
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
To restrict the agent's permissions, [you can define a custom secret](/usage/settings/secrets-settings) `GITLAB_TOKEN`,
which will override the default token assigned to the agent. While the high-permission API token is still requested
and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
@@ -32,7 +33,8 @@ This feature works for personal projects and is available for group projects wit
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but
we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.

View File

@@ -0,0 +1,45 @@
---
title: "Pro Subscription"
description: "Learn about OpenHands Cloud Pro Subscription features and pricing"
---
The OpenHands Pro Subscription unlocks additional features and better pricing when you run OpenHands conversations in OpenHands Cloud.
## Base Features
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 tools like OpenHands CLI or OpenHands Local GUI**
* **$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 features:
* **Bring Your Own LLM Keys:** Bring your own API keys from OpenAI, Anthropic, Mistral, and other providers.
* **Model Choice:** Unlocks access to OpenHands LLM provider models for use within OpenHands Cloud.
* **No Markup Pricing on LLM usage:** When you use the OpenHands LLM provider in OpenHands Cloud, you pay for LLM usage at-cost (zero markup) based on API prices.
## Plan Comparison
Here are the key differences between Pay-as-you-go and Pro subscriptions:
### When running OpenHands conversations in OpenHands Cloud
| | Pay-as-you-go | Pro Subscription |
| :---- | ----- | ----- |
| Monthly price | None \- no commitment | $20/month |
| Can I bring my own LLM key? | No | ✅ Yes |
| Do I pay for LLM usage? | ✅ Yes | ✅ Yes |
| Can I select from different LLMs without bringing my own LLM key? | No \- defaults to Claude Sonnet 4 | ✅ Yes \- via OpenHands LLM provider <br/><br/>[*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) |
| How much am I charged for LLM usage? | **Marked up pricing** \- 2x Claude Sonnet 4 API prices *This markup helps cover the cost of runtime compute.* | **No markup** \- 1x API prices *The $20 monthly subscription covers the cost of runtime compute.* |
### When using the OpenHands LLM Provider outside of OpenHands Cloud
The following applies to **both** the Pay-as-you-go and Pro subscription:
| | Pay-as-you-go or Pro Subscription |
| :---- | :---- |
| Do I have access to multiple models via the OpenHands LLM provider? | ✅ Yes <br/><br/> [*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) |
| Can I generate and refresh OpenHands LLM API keys? | ✅ Yes |
| How much am I charged for LLM usage when I use the OpenHands LLM provider in other AI coding tools? | **No markup** \- pay 1x API prices <br/> [*See models and pricing*](https://docs.all-hands.dev/usage/llms/openhands-llms#pricing) <br/><br/> *Usage is deducted from your OpenHands Cloud credit balance.* <br/><br/> *The OpenHands LLM provider is available to all OpenHands Cloud users, and LLM usage is billed at-cost (zero markup). Use these models with OpenHands CLI, OpenHands Local GUI, or even other AI coding agents\! [Learn more.](https://www.all-hands.dev/blog/access-state-of-the-art-llm-models-at-cost-via-openhands-gui-and-cli)* |

View File

@@ -13,7 +13,9 @@ description: This guide walks you through installing the OpenHands Slack app.
</iframe>
<Info>
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete.
While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to
validate critical information independently.
</Info>
## Prerequisites
@@ -39,7 +41,7 @@ OpenHands utilizes a large language model (LLM), which may generate responses th
**Make sure your Slack workspace admin/owner has installed OpenHands Slack App first.**
Every user in the Slack workspace (including admins/owners) must link their OpenHands Cloud account to the OpenHands Slack App. To do this:
1. Visit [integrations settings](https://app.all-hands.dev/settings/integrations) in OpenHands Cloud.
1. Visit the [Settings > Integrations](https://app.all-hands.dev/settings/integrations) page in OpenHands Cloud.
2. Click `Install OpenHands Slack App`.
3. In the top right corner, select the workspace to install the OpenHands Slack app.
4. Review permissions and click allow.
@@ -57,7 +59,8 @@ To start a new conversation, you can mention `@openhands` in a new message or a
Once a conversation is started, all thread messages underneath it will be follow-up messages to OpenHands.
To send follow-up messages for the same conversation, mention `@openhands` in a thread reply to the original message. You must be the user who started the conversation.
To send follow-up messages for the same conversation, mention `@openhands` in a thread reply to the original message.
You must be the user who started the conversation.
## Example conversation

View File

@@ -85,11 +85,11 @@ You can use the Settings page at any time to:
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Configure MCP servers](/usage/settings/mcp-settings).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
- Set application settings like your preferred language, notifications and other preferences.
- [Manage custom secrets](/usage/common-settings#secrets-management).
- [Manage custom secrets](/usage/settings/secrets-settings).
#### GitHub Setup

View File

@@ -10,12 +10,15 @@ Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
## Supported MCPs
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
OpenHands supports the following MCP transport protocols:
### How MCP Works
* [Server-Sent Events (SSE)](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse)
* [Streamable HTTP (SHTTP)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http)
* [Standard Input/Output (stdio)](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio)
## How MCP Works
When OpenHands starts, it:
@@ -33,15 +36,90 @@ The agent can then use these tools just like any built-in tool. When the agent c
## Configuration
MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The OpenHands UI in the `Settings > MCP` page.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Options
#### SSE Servers
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.
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication.
#### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server.
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication.
- `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>
#### 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>
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
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers.
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access.
- **Local-only environments**: When you don't want to manage additional proxy processes.
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
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
@@ -72,7 +150,7 @@ sse_servers = [
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",
@@ -82,8 +160,6 @@ shttp_servers = [
]
```
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
@@ -105,138 +181,12 @@ stdio_servers = [
]
```
## Configuration Options
### SSE Servers
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
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
- `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.
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
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
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:
### Server-Sent Events (SSE)
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
### Streamable HTTP (SHTTP)
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
#### SHTTP Timeout Best Practices
When configuring SHTTP timeouts, consider these guidelines:
**Timeout Selection:**
- **Database queries**: 30-60 seconds
- **File operations**: 60-300 seconds (depending on file size)
- **Web scraping**: 60-120 seconds
- **Complex calculations**: 300-1800 seconds
- **Batch processing**: 1800-3600 seconds (maximum)
**Error Handling:**
When a tool call exceeds the configured timeout:
- The operation is cancelled with an `asyncio.TimeoutError`
- The agent receives a timeout error message
- The server connection remains active for subsequent requests
**Monitoring:**
- Set timeouts based on your tool's actual performance characteristics
- Monitor timeout occurrences to optimize timeout values
- Consider implementing server-side timeout handling for graceful degradation
### Standard Input/Output (stdio)
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers.
- **Docker-based proxies**: Containerized solutions for better isolation.
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints.

View File

@@ -1,28 +1,19 @@
---
title: OpenHands Settings
description: Overview of some of the settings available in OpenHands.
title: Secrets Management
description: How to manage secrets in OpenHands.
---
## Openhands Cloud vs Running on Your Own
There are some differences between the settings available in OpenHands Cloud and those available when running OpenHands
on your own:
* [OpenHands Cloud settings](/usage/cloud/cloud-ui#settings)
* [Settings available when running on your own](/usage/how-to/gui-mode#settings)
Refer to these pages for more detailed information.
## Secrets Management
## Overview
OpenHands provides a secrets manager that allows you to securely store and manage sensitive information that can be
accessed by the agent during runtime, such as API keys. These secrets are automatically exported as environment
variables in the agent's runtime environment.
### Accessing the Secrets Manager
## Accessing the Secrets Manager
In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of all your existing custom secrets.
Navigate to the `Settings > Secrets` page. Here, you'll see a list of all your existing custom secrets.
### Adding a New Secret
## Adding a New Secret
1. Click `Add a new secret`.
2. Fill in the following fields:
- **Name**: A unique identifier for your secret (e.g., `AWS_ACCESS_KEY`). This will be the environment variable name.
@@ -30,7 +21,7 @@ In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of
- **Description** (optional): A brief description of what the secret is used for, which is also provided to the agent.
3. Click `Add secret` to save.
### Editing a Secret
## Editing a Secret
1. Click the `Edit` button next to the secret you want to modify.
2. You can update the name and description of the secret.
@@ -39,14 +30,13 @@ In the Settings page, navigate to the `Secrets` tab. Here, you'll see a list of
value, delete the secret and create a new one.
</Note>
### Deleting a Secret
## Deleting a Secret
1. Click the `Delete` button next to the secret you want to remove.
2. Select `Confirm` to delete the secret.
### Using Secrets in the Agent
## Using Secrets in the Agent
- All custom secrets are automatically exported as environment variables in the agent's runtime environment.
- You can access them in your code using standard environment variable access methods
(e.g., `os.environ['SECRET_NAME']` in Python).
- Example: If you create a secret named `OPENAI_API_KEY`, you can access it in your code as
`process.env.OPENAI_API_KEY` in JavaScript or `os.environ['OPENAI_API_KEY']` in Python.
- You can access them in your code using standard environment variable access methods. For example, if you create a
secret named `OPENAI_API_KEY`, you can access it in your code as `process.env.OPENAI_API_KEY` in JavaScript or
`os.environ['OPENAI_API_KEY']` in Python.

140
enterprise/poetry.lock generated
View File

@@ -766,7 +766,7 @@ version = "1.17.1"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
@@ -836,6 +836,7 @@ files = [
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[package.dependencies]
pycparser = "*"
@@ -1901,25 +1902,25 @@ files = [
[[package]]
name = "fastapi"
version = "0.116.1"
version = "0.117.1"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"},
{file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"},
{file = "fastapi-0.117.1-py3-none-any.whl", hash = "sha256:33c51a0d21cab2b9722d4e56dbb9316f3687155be6b276191790d8da03507552"},
{file = "fastapi-0.117.1.tar.gz", hash = "sha256:fb2d42082d22b185f904ca0ecad2e195b851030bd6c5e4c032d1c981240c631a"},
]
[package.dependencies]
pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0"
starlette = ">=0.40.0,<0.48.0"
starlette = ">=0.40.0,<0.49.0"
typing-extensions = ">=4.8.0"
[package.extras]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"]
standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"]
[[package]]
name = "fastjsonschema"
@@ -2291,6 +2292,72 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto
test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""]
tqdm = ["tqdm"]
[[package]]
name = "gevent"
version = "25.9.1"
description = "Coroutine-based network library"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0"},
{file = "gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c"},
{file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8"},
{file = "gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975"},
{file = "gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235"},
{file = "gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a"},
{file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff"},
{file = "gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56"},
{file = "gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586"},
{file = "gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74"},
{file = "gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51"},
{file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5"},
{file = "gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f"},
{file = "gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3"},
{file = "gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48"},
{file = "gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7"},
{file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47"},
{file = "gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117"},
{file = "gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa"},
{file = "gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e"},
{file = "gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c"},
{file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f"},
{file = "gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6"},
{file = "gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7"},
{file = "gevent-25.9.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f18f80aef6b1f6907219affe15b36677904f7cfeed1f6a6bc198616e507ae2d7"},
{file = "gevent-25.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b274a53e818124a281540ebb4e7a2c524778f745b7a99b01bdecf0ca3ac0ddb0"},
{file = "gevent-25.9.1-cp39-cp39-win32.whl", hash = "sha256:c6c91f7e33c7f01237755884316110ee7ea076f5bdb9aa0982b6dc63243c0a38"},
{file = "gevent-25.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a44b0121f3d7c800740ff80351c897e85e76a7e4764690f35c5ad9ec17de5"},
{file = "gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd"},
]
[package.dependencies]
cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""}
"zope.event" = "*"
"zope.interface" = "*"
[package.extras]
dnspython = ["dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\""]
docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"]
monitor = ["psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
recommended = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
test = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"]
[[package]]
name = "gitdb"
version = "4.0.12"
@@ -2707,7 +2774,7 @@ version = "3.2.4"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c"},
{file = "greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590"},
@@ -2764,6 +2831,7 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
]
markers = {test = "platform_python_implementation == \"CPython\""}
[package.extras]
docs = ["Sphinx", "furo"]
@@ -5363,7 +5431,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-ai"
version = "0.55.0"
version = "0.57.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5397,7 +5465,7 @@ json-repair = "*"
jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.37,<0.40"
litellm = "^1.74.3, !=1.64.4, !=1.67.*"
litellm = ">=1.74.3, <1.77.2, !=1.64.4, !=1.67.*"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
@@ -5406,6 +5474,7 @@ opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
pexpect = "*"
pillow = "^11.3.0"
poetry = "^2.1.2"
prompt-toolkit = "^3.0.50"
protobuf = "^5.0.0,<6.0.0"
@@ -5413,6 +5482,7 @@ psutil = "*"
pygithub = "^2.5.0"
pyjwt = "^2.9.0"
pylatexenc = "*"
pypdf = "^6.0.0"
PyPDF2 = "*"
python-docx = "*"
python-dotenv = "*"
@@ -5426,13 +5496,17 @@ pyyaml = "^6.0.2"
qtconsole = "^5.6.1"
rapidfuzz = "^3.9.0"
redis = ">=5.2,<7.0"
requests = "^2.32.5"
setuptools = ">=78.1.1"
shellingham = "^1.5.4"
sse-starlette = "^2.1.3"
sse-starlette = "^3.0.2"
starlette = "^0.48.0"
tenacity = ">=8.5,<10.0"
termcolor = "*"
toml = "*"
tornado = "*"
types-toml = "*"
urllib3 = "^2.5.0"
uvicorn = "*"
whatthepatch = "^1.0.6"
zope-interface = "7.2"
@@ -6471,11 +6545,12 @@ version = "2.22"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
]
markers = {test = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
[[package]]
name = "pydantic"
@@ -8265,7 +8340,7 @@ version = "80.9.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.9"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"},
{file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"},
@@ -8631,14 +8706,14 @@ sqlcipher = ["sqlcipher3_binary"]
[[package]]
name = "sse-starlette"
version = "2.4.1"
version = "3.0.2"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"},
{file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"},
{file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"},
{file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"},
]
[package.dependencies]
@@ -8646,7 +8721,7 @@ anyio = ">=4.7.0"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio,examples] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
@@ -8702,14 +8777,14 @@ files = [
[[package]]
name = "starlette"
version = "0.47.3"
version = "0.48.0"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51"},
{file = "starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9"},
{file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"},
{file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"},
]
[package.dependencies]
@@ -9838,13 +9913,32 @@ enabler = ["pytest-enabler (>=2.2)"]
test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
type = ["pytest-mypy"]
[[package]]
name = "zope-event"
version = "6.0"
description = "Very basic event publishing system"
optional = false
python-versions = ">=3.9"
groups = ["test"]
files = [
{file = "zope_event-6.0-py3-none-any.whl", hash = "sha256:6f0922593407cc673e7d8766b492c519f91bdc99f3080fe43dcec0a800d682a3"},
{file = "zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92"},
]
[package.dependencies]
setuptools = ">=75.8.2"
[package.extras]
docs = ["Sphinx"]
test = ["zope.testrunner (>=6.4)"]
[[package]]
name = "zope-interface"
version = "7.2"
description = "Interfaces for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
groups = ["main", "test"]
files = [
{file = "zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2"},
{file = "zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a"},
@@ -10008,4 +10102,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 = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
content-hash = "8c460070dce6bdec5ee0ee7bc0c2246fcf2602d1e64a0867b4f5e3a0e334fe93"

View File

@@ -63,6 +63,7 @@ openai = "*"
opencv-python = "*"
pandas = "*"
reportlab = "*"
gevent = ">=24.2.1,<26.0.0"
[tool.poetry-dynamic-versioning]
enable = true
@@ -85,3 +86,7 @@ lint.pydocstyle.convention = "google"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.coverage.run]
relative_files = true
omit = [ "tests/*" ]

View File

@@ -61,38 +61,6 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
}),
}));
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useSelector: vi.fn((selector) => {
// Create a mock state object
const mockState = {
agent: {
curAgentState: "AWAITING_USER_INPUT",
},
initialQuery: {
selectedRepository: null,
replayJson: null,
},
conversation: {
messageToSend: null,
files: [],
images: [],
loadingFiles: [],
loadingImages: [],
},
status: {
curStatusMessage: null,
},
};
// Execute the selector function with our mock state
return selector(mockState);
}),
useDispatch: vi.fn(() => vi.fn()),
};
});
// Helper function to render with Router context
const renderChatInterfaceWithRouter = () =>

View File

@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { renderWithQueryAndI18n } from "test-utils";
import { renderWithProviders } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
@@ -17,7 +17,7 @@ describe("ConversationPanel", () => {
},
]);
const renderConversationPanel = () => renderWithQueryAndI18n(<RouterStub />);
const renderConversationPanel = () => renderWithProviders(<RouterStub />);
beforeAll(() => {
vi.mock("react-router", async (importOriginal) => ({
@@ -287,7 +287,7 @@ describe("ConversationPanel", () => {
},
]);
renderWithQueryAndI18n(<MyRouterStub />);
renderWithProviders(<MyRouterStub />);
const toggleButton = screen.getByText("Toggle");

View File

@@ -6,31 +6,11 @@ import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
// Mock the conversation slice actions
vi.mock("#/state/conversation-slice", () => ({
setShouldStopConversation: vi.fn(),
setShouldStartConversation: vi.fn(),
default: {
name: "conversation",
initialState: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
},
reducers: {},
},
}));
// Mock react-redux
vi.mock("react-redux", () => ({
useSelector: vi.fn((selector) => {
// Mock the selector to return different agent states based on test needs
return {
curAgentState: AgentState.RUNNING,
};
}),
Provider: ({ children }: { children: React.ReactNode }) => children,
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
}));
// Mock the custom hooks
@@ -86,11 +66,23 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Helper function to mock agent store with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentStore).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
};
afterEach(() => {
vi.clearAllMocks();
});
it("should render server status with different conversation statuses", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
@@ -112,6 +104,10 @@ describe("ServerStatus", () => {
it("should show context menu when clicked with RUNNING status", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
@@ -128,6 +124,10 @@ describe("ServerStatus", () => {
it("should show context menu when clicked with STOPPED status", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
@@ -144,6 +144,10 @@ describe("ServerStatus", () => {
it("should not show context menu when clicked with other statuses", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
const statusContainer = screen.getByText("Running").closest("div");
@@ -163,6 +167,9 @@ describe("ServerStatus", () => {
// Clear previous calls
mockStopConversationMutate.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
@@ -182,6 +189,9 @@ describe("ServerStatus", () => {
// Clear previous calls
mockStartConversationMutate.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
@@ -198,6 +208,10 @@ describe("ServerStatus", () => {
it("should close context menu after stop server action", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
@@ -214,6 +228,10 @@ describe("ServerStatus", () => {
it("should close context menu after start server action", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
@@ -229,6 +247,9 @@ describe("ServerStatus", () => {
});
it("should handle null conversation status", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
const statusText = screen.getByText("Running");

View File

@@ -1,7 +1,5 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import { HomeHeader } from "#/components/features/home/home-header/home-header";
@@ -26,11 +24,9 @@ vi.mock("react-i18next", async () => {
const renderHomeHeader = () => {
return render(<HomeHeader />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
};

View File

@@ -1,8 +1,6 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -43,11 +41,9 @@ const renderNewConversation = () => {
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
};

View File

@@ -2,8 +2,6 @@ import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/settings-service/settings-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
@@ -42,11 +40,9 @@ const renderRepoConnector = () => {
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
};

View File

@@ -2,9 +2,7 @@ import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import UserService from "#/api/user-service/user-service.api";
import GitService from "#/api/git-service/git-service.api";
@@ -41,11 +39,9 @@ const renderTaskCard = (task = MOCK_TASK_1) => {
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
};

View File

@@ -1,9 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
@@ -62,11 +60,9 @@ const renderTaskSuggestions = () => {
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
};

View File

@@ -5,6 +5,18 @@ import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useConversationStore } from "#/state/conversation-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
}));
// Mock the conversation store
vi.mock("#/state/conversation-store", () => ({
useConversationStore: vi.fn(),
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
@@ -47,6 +59,49 @@ describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to mock stores
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentStore).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
vi.mocked(useConversationStore).mockReturnValue({
images: [],
files: [],
addImages: vi.fn(),
addFiles: vi.fn(),
clearAllFiles: vi.fn(),
addFileLoading: vi.fn(),
removeFileLoading: vi.fn(),
addImageLoading: vi.fn(),
removeImageLoading: vi.fn(),
submittedMessage: null,
setShouldHideSuggestions: vi.fn(),
setSubmittedMessage: vi.fn(),
isRightPanelShown: true,
selectedTab: "editor" as const,
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
shouldHideSuggestions: false,
hasRightPanelToggled: true,
setIsRightPanelShown: vi.fn(),
setSelectedTab: vi.fn(),
setShouldShownAgentLoading: vi.fn(),
removeImage: vi.fn(),
removeFile: vi.fn(),
clearImages: vi.fn(),
clearFiles: vi.fn(),
clearAllLoading: vi.fn(),
setMessageToSend: vi.fn(),
resetConversationState: vi.fn(),
setHasRightPanelToggled: vi.fn(),
});
};
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
@@ -68,22 +123,12 @@ describe("InteractiveChatBox", () => {
});
it("should render", () => {
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const chatBox = screen.getByTestId("interactive-chat-box");
expect(chatBox).toBeInTheDocument();
@@ -91,33 +136,12 @@ describe("InteractiveChatBox", () => {
it("should set custom values", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
);
mockStores(AgentState.AWAITING_USER_INPUT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textbox = screen.getByTestId("chat-input");
@@ -129,22 +153,12 @@ describe("InteractiveChatBox", () => {
it("should display the image previews when images are uploaded", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// Create a larger file to ensure it passes validation
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
@@ -166,22 +180,12 @@ describe("InteractiveChatBox", () => {
it("should remove the image preview when the close button is clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
const file = new File([fileContent], "chucknorris.png", {
@@ -201,22 +205,12 @@ describe("InteractiveChatBox", () => {
it("should call onSubmit with the message and images", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textarea = screen.getByTestId("chat-input");
@@ -242,22 +236,12 @@ describe("InteractiveChatBox", () => {
it("should disable the submit button when agent is loading", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.LOADING,
},
},
},
);
mockStores(AgentState.LOADING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const button = screen.getByTestId("submit-button");
expect(button).toBeDisabled();
@@ -268,23 +252,14 @@ describe("InteractiveChatBox", () => {
it("should display the stop button when agent is running and call onStop when clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,
},
},
},
);
mockStores(AgentState.RUNNING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// The stop button should be available when agent is running
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
@@ -297,33 +272,12 @@ describe("InteractiveChatBox", () => {
const onSubmit = vi.fn();
const onStop = vi.fn();
const { rerender } = renderInteractiveChatBox(
{
onSubmit: onSubmit,
onStop: onStop,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
);
mockStores(AgentState.AWAITING_USER_INPUT);
const { rerender } = renderInteractiveChatBox({
onSubmit: onSubmit,
onStop: onStop,
});
// Verify text input has the initial value
const textarea = screen.getByTestId("chat-input");

View File

@@ -1,42 +1,46 @@
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
import { jupyterReducer } from "#/state/jupyter-slice";
import { vi, describe, it, expect } from "vitest";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useJupyterStore } from "#/state/jupyter-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("JupyterEditor", () => {
const mockStore = configureStore({
reducer: {
fileState: () => ({}),
initalQuery: () => ({}),
browser: () => ({}),
chat: () => ({}),
code: () => ({}),
cmd: () => ({}),
agent: () => ({}),
jupyter: jupyterReducer,
securityAnalyzer: () => ({}),
status: () => ({}),
},
preloadedState: {
jupyter: {
cells: Array(20).fill({
content: "Test cell content",
type: "input",
output: "Test output",
}),
},
},
beforeEach(() => {
// Reset the Zustand store before each test
useJupyterStore.setState({
cells: Array(20).fill({
content: "Test cell content",
type: "input",
imageUrls: undefined,
}),
});
});
it("should have a scrollable container", () => {
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentStore).mockReturnValue({
curAgentState: AgentState.RUNNING,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
render(
<Provider store={mockStore}>
<div style={{ height: "100vh" }}>
<JupyterEditor maxWidth={800} />
</div>
</Provider>
<div style={{ height: "100vh" }}>
<JupyterEditor maxWidth={800} />
</div>,
);
const container = screen.getByTestId("jupyter-container");

View File

@@ -5,19 +5,17 @@ import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useDispatch: () => vi.fn(),
useSelector: () => ({
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
}),
};
});
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
}));
// Mock the conversation ID hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("MicroagentsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
@@ -47,10 +45,17 @@ describe("MicroagentsModal - Refresh Button", () => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getUserConversations
// Setup default mock for getMicroagents
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
// Mock the agent store to return a ready state
vi.mocked(useAgentStore).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
});
afterEach(() => {
@@ -58,10 +63,11 @@ describe("MicroagentsModal - Refresh Button", () => {
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", () => {
it("should render the refresh button with correct text and test ID", async () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshButton = screen.getByTestId("refresh-microagents");
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-microagents");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
@@ -75,7 +81,8 @@ describe("MicroagentsModal - Refresh Button", () => {
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
const refreshButton = screen.getByTestId("refresh-microagents");
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-microagents");
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);

View File

@@ -4,6 +4,7 @@ import { useTerminal } from "#/hooks/use-terminal";
import { Command, useCommandStore } from "#/state/command-store";
import { AgentState } from "#/types/agent-state";
import { renderWithProviders } from "../../test-utils";
import { useAgentStore } from "#/stores/agent-store";
// Mock the WsClient context
vi.mock("#/context/ws-client-provider", () => ({
@@ -22,6 +23,8 @@ interface TestTerminalComponentProps {
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
// Set commands in Zustand store
useCommandStore.setState({ commands });
// Set agent state in Zustand store
useAgentStore.setState({ curAgentState: AgentState.RUNNING });
const ref = useTerminal();
return <div ref={ref} />;
}
@@ -57,11 +60,7 @@ describe("useTerminal", () => {
});
it("should render", () => {
renderWithProviders(<TestTerminalComponent commands={[]} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
},
});
renderWithProviders(<TestTerminalComponent commands={[]} />);
});
it("should render the commands in the terminal", () => {
@@ -70,11 +69,7 @@ describe("useTerminal", () => {
{ content: "hello", type: "output" },
];
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
},
});
renderWithProviders(<TestTerminalComponent commands={commands} />);
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
@@ -92,11 +87,7 @@ describe("useTerminal", () => {
{ content: secret, type: "output" },
];
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
},
});
renderWithProviders(<TestTerminalComponent commands={commands} />);
// This test is no longer relevant as secrets filtering has been removed
});

View File

@@ -3,8 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { Provider } from "react-redux";
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
import { createAxiosNotFoundErrorObject } from "test-utils";
import HomeScreen from "#/routes/home";
import { GitRepository } from "#/types/git";
import SettingsService from "#/settings-service/settings-service.api";
@@ -66,11 +65,9 @@ const selectRepository = async (repoName: string) => {
const renderHomeScreen = () =>
render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});

View File

@@ -21,8 +21,12 @@ vi.mock("#/state/command-store", () => ({
},
}));
vi.mock("#/state/jupyter-slice", () => ({
appendJupyterInput: mockAppendJupyterInput,
vi.mock("#/state/jupyter-store", () => ({
useJupyterStore: {
getState: () => ({
appendJupyterInput: mockAppendJupyterInput,
}),
},
}));
vi.mock("#/state/metrics-slice", () => ({
@@ -81,8 +85,8 @@ describe("handleActionMessage", () => {
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockDispatch).toHaveBeenCalledWith(
mockAppendJupyterInput("print('Hello from Jupyter!')"),
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
"print('Hello from Jupyter!')",
);
expect(mockAppendInput).not.toHaveBeenCalled();
});

View File

@@ -15,7 +15,6 @@
"@react-router/node": "^7.9.1",
"@react-router/serve": "^7.9.1",
"@react-types/shared": "^3.32.0",
"@reduxjs/toolkit": "^2.9.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.13",
@@ -47,7 +46,6 @@
"react-i18next": "^15.7.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.9.1",
"react-syntax-highlighter": "^15.6.6",
"remark-breaks": "^4.0.0",
@@ -4977,32 +4975,6 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^10.0.3",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.35",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
@@ -5337,18 +5309,6 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
@@ -6350,12 +6310,6 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -10830,6 +10784,8 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -14754,29 +14710,6 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -14879,21 +14812,6 @@
"node": ">=8"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -15164,12 +15082,6 @@
"node": ">=0.10.5"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",

View File

@@ -14,7 +14,6 @@
"@react-router/node": "^7.9.1",
"@react-router/serve": "^7.9.1",
"@react-types/shared": "^3.32.0",
"@reduxjs/toolkit": "^2.9.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.13",
@@ -46,7 +45,6 @@
"react-i18next": "^15.7.2",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.9.1",
"react-syntax-highlighter": "^15.6.6",
"remark-breaks": "^4.0.0",

View File

@@ -1,4 +1,3 @@
import { useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
@@ -7,7 +6,6 @@ import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
@@ -19,6 +17,7 @@ import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useAgentStore } from "#/stores/agent-store";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -63,7 +62,7 @@ export function ChatInterface() {
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"

View File

@@ -1,4 +1,3 @@
import { PayloadAction } from "@reduxjs/toolkit";
import { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import Markdown from "react-markdown";
@@ -30,8 +29,8 @@ interface ExpandableMessageProps {
message: string;
type: string;
success?: boolean;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
observation?: { payload: OpenHandsObservation };
action?: { payload: OpenHandsAction };
}
export function ExpandableMessage({

View File

@@ -1,4 +1,3 @@
import { useSelector } from "react-redux";
import { isFileImage } from "#/utils/is-file-image";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { validateFiles } from "#/utils/file-validation";
@@ -7,8 +6,8 @@ import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
import { processFiles, processImages } from "#/utils/file-processing";
import { RootState } from "#/store";
interface InteractiveChatBoxProps {
onSubmit: (message: string, images: File[], files: File[]) => void;
@@ -30,9 +29,7 @@ export function InteractiveChatBox({
addImageLoading,
removeImageLoading,
} = useConversationStore();
const curAgentState = useSelector(
(state: RootState) => state.agent.curAgentState,
);
const { curAgentState } = useAgentStore();
const { data: conversation } = useActiveConversation();
// Helper function to validate and filter files

View File

@@ -1,7 +1,5 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { useEffect } from "react";
import { RootState } from "#/store";
import { useStatusStore } from "#/state/status-store";
import { useWsClient } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
@@ -14,6 +12,7 @@ import { cn } from "#/utils/utils";
import { AgentLoading } from "./agent-loading";
import { useConversationStore } from "#/state/conversation-store";
import CircleErrorIcon from "#/icons/circle-error.svg?react";
import { useAgentStore } from "#/stores/agent-store";
export interface AgentStatusProps {
className?: string;
@@ -30,7 +29,7 @@ export function AgentStatus({
}: AgentStatusProps) {
const { t } = useTranslation();
const { setShouldShownAgentLoading } = useConversationStore();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const { curStatusMessage } = useStatusStore();
const { webSocketStatus } = useWsClient();
const { data: conversation } = useActiveConversation();

View File

@@ -1,16 +1,15 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { ServerStatusContextMenu } from "./server-status-context-menu";
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { useAgentStore } from "#/stores/agent-store";
export interface ServerStatusProps {
className?: string;
@@ -23,7 +22,7 @@ export function ServerStatus({
}: ServerStatusProps) {
const [showContextMenu, setShowContextMenu] = useState(false);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const { t } = useTranslation();
const { conversationId } = useConversationId();

View File

@@ -1,17 +1,16 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { I18nKey } from "#/i18n/declaration";
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { Typography } from "#/ui/typography";
import { MicroagentsModalHeader } from "./microagents-modal-header";
import { MicroagentsLoadingState } from "./microagents-loading-state";
import { MicroagentsEmptyState } from "./microagents-empty-state";
import { MicroagentItem } from "./microagent-item";
import { useAgentStore } from "#/stores/agent-store";
interface MicroagentsModalProps {
onClose: () => void;
@@ -19,7 +18,7 @@ interface MicroagentsModalProps {
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
const { t } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);

View File

@@ -1,4 +1,3 @@
import { useSelector } from "react-redux";
import { FaExternalLinkAlt } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -6,10 +5,10 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useConversationId } from "#/hooks/use-conversation-id";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { RootState } from "#/store";
import { useAgentStore } from "#/stores/agent-store";
export function VSCodeTooltipContent() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const { t } = useTranslation();
const { conversationId } = useConversationId();

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Cell } from "#/state/jupyter-slice";
import { Cell } from "#/state/jupyter-store";
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
import { JupytrerCellInput } from "./jupyter-cell-input";
import { JupyterCellOutput } from "./jupyter-cell-output";

View File

@@ -1,7 +1,5 @@
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { JupyterCell } from "./jupyter-cell";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
@@ -9,14 +7,17 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
import { useAgentStore } from "#/stores/agent-store";
import { useJupyterStore } from "#/state/jupyter-store";
interface JupyterEditorProps {
maxWidth: number;
}
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const cells = useSelector((state: RootState) => state.jupyter?.cells ?? []);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const cells = useJupyterStore((state) => state.cells);
const jupyterRef = React.useRef<HTMLDivElement>(null);

View File

@@ -1,13 +1,12 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { useTerminal } from "#/hooks/use-terminal";
import "@xterm/xterm/css/xterm.css";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { cn } from "#/utils/utils";
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
import { useAgentStore } from "#/stores/agent-store";
function Terminal() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);

View File

@@ -8,11 +8,9 @@
import { HydratedRouter } from "react-router/dom";
import React, { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import { QueryClientProvider } from "@tanstack/react-query";
import store from "./store";
import OptionService from "./api/option-service/option-service.api";
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { queryClient } from "./query-client-config";
@@ -63,12 +61,10 @@ prepareApp().then(() =>
hydrateRoot(
document,
<StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
</QueryClientProvider>
</Provider>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
</QueryClientProvider>
<div id="modal-portal-exit" />
</StrictMode>,
);

View File

@@ -1,13 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { useSelector } from "react-redux";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "../use-conversation-id";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
export const useConversationMicroagents = () => {
const { conversationId } = useConversationId();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
return useQuery({
queryKey: ["conversation", conversationId, "microagents"],

View File

@@ -1,10 +1,8 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
export const useHandleRuntimeActive = () => {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);

View File

@@ -1,7 +1,6 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useActiveConversation } from "./query/use-active-conversation";
import { useAgentStore } from "#/stores/agent-store";
/**
* Hook to determine if the runtime is ready for operations
@@ -10,7 +9,7 @@ import { useActiveConversation } from "./query/use-active-conversation";
*/
export const useRuntimeIsReady = (): boolean => {
const { data: conversation } = useActiveConversation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
return (
conversation?.status === "RUNNING" &&

View File

@@ -1,13 +1,12 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import React from "react";
import { useSelector } from "react-redux";
import { Command, useCommandStore } from "#/state/command-store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
import { getTerminalCommand } from "#/services/terminal-service";
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
import { RootState } from "#/store";
import { useAgentStore } from "#/stores/agent-store";
/*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@@ -38,7 +37,7 @@ const persistentLastCommandIndex = { current: 0 };
export const useTerminal = () => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const commands = useCommandStore((state) => state.commands);
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);

View File

@@ -408,6 +408,8 @@ export enum I18nKey {
SETTINGS$OPENHANDS_API_KEY_HELP = "SETTINGS$OPENHANDS_API_KEY_HELP",
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO",
SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS",
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY",

View File

@@ -6512,20 +6512,52 @@
"uk": "Ви можете знайти свій ключ API OpenHands у"
},
"SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": {
"en": "tab of OpenHands Cloud. LLM usage is billed at the providers' rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ja": "タブで確認できます。LLMの使用料金は、プロバイダーの料金でマークアップなしで請求されます。詳細: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-CN": "标签页中找到您的OpenHands API密钥。LLM使用费用按提供商费率计费无加价。详情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。LLM使用費用按提供商費率計費無加價。詳情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ko-KR": "탭에서 찾을 수 있습니다. LLM 사용료는 제공업체 요금으로 마크업 없이 청구됩니다. 자세한 내용: https://docs.all-hands.dev/usage/llms/openhands-llms",
"no": "-fanen i OpenHands Cloud. LLM-bruk faktureres til leverandørenes priser uten påslag. Detaljer: https://docs.all-hands.dev/usage/llms/openhands-llms",
"it": "scheda di OpenHands Cloud. L'utilizzo di LLM viene fatturato alle tariffe dei fornitori senza ricarico. Dettagli: https://docs.all-hands.dev/usage/llms/openhands-llms",
"pt": "guia do OpenHands Cloud. O uso de LLM é cobrado nas tarifas dos provedores sem markup. Detalhes: https://docs.all-hands.dev/usage/llms/openhands-llms",
"es": "pestaña de OpenHands Cloud. El uso de LLM se factura a las tarifas de los proveedores sin recargo. Detalles: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ar": "علامة التبويب في OpenHands Cloud. يتم فوترة استخدام LLM بأسعار المزودين بدون زيادة. التفاصيل: https://docs.all-hands.dev/usage/llms/openhands-llms",
"fr": "l'onglet d'OpenHands Cloud. L'utilisation de LLM est facturée aux tarifs des fournisseurs sans majoration. Détails : https://docs.all-hands.dev/usage/llms/openhands-llms",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz. LLM kullanımı, sağlayıcıların oranlarında ek ücret olmadan faturalandırılır. Ayrıntılar: https://docs.all-hands.dev/usage/llms/openhands-llms",
"de": "Tab von OpenHands Cloud. LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"uk": "вкладці OpenHands Cloud. Використання LLM оплачується за тарифами провайдерів без надбавки. Деталі: https://docs.all-hands.dev/usage/llms/openhands-llms"
"en": "tab of OpenHands Cloud.",
"ja": "タブで確認できます。",
"zh-CN": "标签页中找到您的OpenHands API密钥。",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。",
"ko-KR": "탭에서 찾을 수 있습니다.",
"no": "-fanen i OpenHands Cloud.",
"it": "scheda di OpenHands Cloud.",
"pt": "guia do OpenHands Cloud.",
"es": "pestaña de OpenHands Cloud.",
"ar": "علامة التبويب في OpenHands Cloud.",
"fr": "l'onglet d'OpenHands Cloud.",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.",
"de": "Tab von OpenHands Cloud.",
"uk": "вкладці OpenHands Cloud."
},
"SETTINGS$LLM_BILLING_INFO": {
"en": "LLM usage is billed at the providers' rates with no markup.",
"ja": "LLMの使用料金は、プロバイダーの料金でマークアップなしで請求されます。",
"zh-CN": "LLM使用费用按提供商费率计费无加价。",
"zh-TW": "LLM使用費用按提供商費率計費無加價。",
"ko-KR": "LLM 사용료는 제공업체 요금으로 마크업 없이 청구됩니다。",
"no": "LLM-bruk faktureres til leverandørenes priser uten påslag.",
"it": "L'utilizzo di LLM viene fatturato alle tariffe dei fornitori senza ricarico.",
"pt": "O uso de LLM é cobrado nas tarifas dos provedores sem markup.",
"es": "El uso de LLM se factura a las tarifas de los proveedores sin recargo.",
"ar": "يتم فوترة استخدام LLM بأسعار المزودين بدون زيادة.",
"fr": "L'utilisation de LLM est facturée aux tarifs des fournisseurs sans majoration.",
"tr": "LLM kullanımı, sağlayıcıların oranlarında ek ücret olmadan faturalandırılır.",
"de": "LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet.",
"uk": "Використання LLM оплачується за тарифами провайдерів без надбавки."
},
"SETTINGS$SEE_PRICING_DETAILS": {
"en": "See pricing details.",
"ja": "価格詳細を見る。",
"zh-CN": "查看价格详情。",
"zh-TW": "查看價格詳情。",
"ko-KR": "가격 세부정보 보기。",
"no": "Se prisdetaljer.",
"it": "Vedi dettagli sui prezzi.",
"pt": "Ver detalhes de preços.",
"es": "Ver detalles de precios.",
"ar": "انظر تفاصيل الأسعار.",
"fr": "Voir les détails de prix.",
"tr": "Fiyat ayrıntılarını gör.",
"de": "Preisdetails anzeigen.",
"uk": "Переглянути деталі цін."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key",

View File

@@ -1,4 +1,3 @@
import { PayloadAction } from "@reduxjs/toolkit";
import { OpenHandsObservation } from "./types/core/observations";
import { OpenHandsAction } from "./types/core/actions";
@@ -12,6 +11,6 @@ export type Message = {
pending?: boolean;
translationID?: string;
eventID?: number;
observation?: PayloadAction<OpenHandsObservation>;
action?: PayloadAction<OpenHandsAction>;
observation?: { payload: OpenHandsObservation };
action?: { payload: OpenHandsAction };
};

View File

@@ -1,13 +1,12 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import React from "react";
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
import { useAgentStore } from "#/stores/agent-store";
// Error message patterns
const GIT_REPO_ERROR_PATTERN = /not a git repository/i;
@@ -34,7 +33,7 @@ function GitChanges() {
null,
);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
const isNotGitRepoError =

View File

@@ -1,14 +1,13 @@
import React from "react";
import { useNavigate } from "react-router";
import { useDispatch } from "react-redux";
import { useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCommandStore } from "#/state/command-store";
import { useEffectOnce } from "#/hooks/use-effect-once";
import { clearJupyter } from "#/state/jupyter-slice";
import { useJupyterStore } from "#/state/jupyter-store";
import { useConversationStore } from "#/state/conversation-store";
import { setCurrentAgentState } from "#/state/agent-slice";
import { useAgentStore } from "#/stores/agent-store";
import { AgentState } from "#/types/agent-state";
import { useBatchFeedback } from "#/hooks/query/use-batch-feedback";
@@ -39,9 +38,12 @@ function AppContent() {
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
const { resetConversationState } = useConversationStore();
const dispatch = useDispatch();
const navigate = useNavigate();
const clearTerminal = useCommandStore((state) => state.clearTerminal);
const setCurrentAgentState = useAgentStore(
(state) => state.setCurrentAgentState,
);
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
const queryClient = useQueryClient();
// Fetch batch feedback data when conversation is loaded
@@ -86,16 +88,21 @@ function AppContent() {
React.useEffect(() => {
clearTerminal();
dispatch(clearJupyter());
clearJupyter();
resetConversationState();
dispatch(setCurrentAgentState(AgentState.LOADING));
}, [conversationId, clearTerminal, resetConversationState]);
setCurrentAgentState(AgentState.LOADING);
}, [
conversationId,
clearTerminal,
setCurrentAgentState,
resetConversationState,
]);
useEffectOnce(() => {
clearTerminal();
dispatch(clearJupyter());
clearJupyter();
resetConversationState();
dispatch(setCurrentAgentState(AgentState.LOADING));
setCurrentAgentState(AgentState.LOADING);
});
return (

View File

@@ -34,6 +34,37 @@ import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/us
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { cn } from "#/utils/utils";
interface OpenHandsApiKeyHelpProps {
testId: string;
}
function OpenHandsApiKeyHelp({ testId }: OpenHandsApiKeyHelpProps) {
const { t } = useTranslation();
return (
<>
<HelpLink
testId={testId}
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
/>
<p className="text-xs">
{t(I18nKey.SETTINGS$LLM_BILLING_INFO)}{" "}
<a
href="https://docs.all-hands.dev/usage/llms/openhands-llms"
rel="noreferrer noopener"
target="_blank"
className="underline underline-offset-2"
>
{t(I18nKey.SETTINGS$SEE_PRICING_DETAILS)}
</a>
</p>
</>
);
}
function LlmSettingsScreen() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -473,13 +504,7 @@ function LlmSettingsScreen() {
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<HelpLink
testId="openhands-api-key-help"
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
/>
<OpenHandsApiKeyHelp testId="openhands-api-key-help" />
)}
</>
)}
@@ -554,13 +579,7 @@ function LlmSettingsScreen() {
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<HelpLink
testId="openhands-api-key-help-2"
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={` ${t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}`}
/>
<OpenHandsApiKeyHelp testId="openhands-api-key-help-2" />
)}
<SettingsInput

View File

@@ -1,17 +1,16 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
import { useAgentStore } from "#/stores/agent-store";
function VSCodeTab() {
const { t } = useTranslation();
const { data, isLoading, error } = useVSCodeUrl();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState } = useAgentStore();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [isCrossProtocol, setIsCrossProtocol] = useState(false);

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { handleStatusMessage } from "../actions";
import { StatusMessage } from "#/types/message";
import { queryClient } from "#/query-client-config";
import store from "#/store";
import { useStatusStore } from "#/state/status-store";
import { trackError } from "#/utils/error-handler";
@@ -13,12 +12,6 @@ vi.mock("#/query-client-config", () => ({
},
}));
vi.mock("#/store", () => ({
default: {
dispatch: vi.fn(),
},
}));
vi.mock("#/state/status-store", () => ({
useStatusStore: {
getState: vi.fn(() => ({
@@ -56,9 +49,6 @@ describe("handleStatusMessage", () => {
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["user", "conversation", "conversation-123"],
});
// Verify that store.dispatch was not called
expect(store.dispatch).not.toHaveBeenCalled();
});
it("should call setCurStatusMessage for info messages without conversation_title", () => {
@@ -109,9 +99,6 @@ describe("handleStatusMessage", () => {
metadata: { msgId: "ERROR_ID" },
});
// Verify that store.dispatch was not called
expect(store.dispatch).not.toHaveBeenCalled();
// Verify that queryClient.invalidateQueries was not called
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
});

View File

@@ -1,7 +1,6 @@
import { trackError } from "#/utils/error-handler";
import useMetricsStore from "#/stores/metrics-store";
import { useStatusStore } from "#/state/status-store";
import store from "#/store";
import ActionType from "#/types/action-type";
import {
ActionMessage,
@@ -9,8 +8,8 @@ import {
StatusMessage,
} from "#/types/message";
import { handleObservationMessage } from "./observations";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterInput } from "#/state/jupyter-slice";
import { queryClient } from "#/query-client-config";
import {
ActionSecurityRisk,
@@ -37,7 +36,7 @@ export function handleActionMessage(message: ActionMessage) {
}
if (message.action === ActionType.RUN_IPYTHON) {
store.dispatch(appendJupyterInput(message.args.code));
useJupyterStore.getState().appendJupyterInput(message.args.code);
}
if ("args" in message && "security_risk" in message.args) {

View File

@@ -1,10 +1,10 @@
import { setCurrentAgentState } from "#/state/agent-slice";
import store from "#/store";
import { ObservationMessage } from "#/types/message";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import { appendJupyterOutput } from "#/state/jupyter-slice";
import ObservationType from "#/types/observation-type";
import { useBrowserStore } from "#/stores/browser-store";
import { useAgentStore } from "#/stores/agent-store";
import { AgentState } from "#/types/agent-state";
export function handleObservationMessage(message: ObservationMessage) {
switch (message.observation) {
@@ -23,14 +23,12 @@ export function handleObservationMessage(message: ObservationMessage) {
break;
}
case ObservationType.RUN_IPYTHON:
store.dispatch(
appendJupyterOutput({
content: message.content,
imageUrls: Array.isArray(message.extras?.image_urls)
? message.extras.image_urls
: undefined,
}),
);
useJupyterStore.getState().appendJupyterOutput({
content: message.content,
imageUrls: Array.isArray(message.extras?.image_urls)
? message.extras.image_urls
: undefined,
});
break;
case ObservationType.BROWSE:
case ObservationType.BROWSE_INTERACTIVE:
@@ -45,7 +43,11 @@ export function handleObservationMessage(message: ObservationMessage) {
}
break;
case ObservationType.AGENT_STATE_CHANGED:
store.dispatch(setCurrentAgentState(message.extras.agent_state));
if (typeof message.extras.agent_state === "string") {
useAgentStore
.getState()
.setCurrentAgentState(message.extras.agent_state as AgentState);
}
break;
case ObservationType.DELEGATE:
case ObservationType.READ:

View File

@@ -1,18 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
import { AgentState } from "#/types/agent-state";
export const agentSlice = createSlice({
name: "agent",
initialState: {
curAgentState: AgentState.LOADING,
},
reducers: {
setCurrentAgentState: (state, action) => {
state.curAgentState = action.payload;
},
},
});
export const { setCurrentAgentState } = agentSlice.actions;
export default agentSlice.reducer;

View File

@@ -1,37 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export type Cell = {
content: string;
type: "input" | "output";
imageUrls?: string[];
};
const initialCells: Cell[] = [];
export const jupyterSlice = createSlice({
name: "jupyter",
initialState: {
cells: initialCells,
},
reducers: {
appendJupyterInput: (state, action) => {
state.cells.push({ content: action.payload, type: "input" });
},
appendJupyterOutput: (state, action) => {
state.cells.push({
content: action.payload.content,
type: "output",
imageUrls: action.payload.imageUrls,
});
},
clearJupyter: (state) => {
state.cells = [];
},
},
});
export const { appendJupyterInput, appendJupyterOutput, clearJupyter } =
jupyterSlice.actions;
export const jupyterReducer = jupyterSlice.reducer;
export default jupyterReducer;

View File

@@ -0,0 +1,40 @@
import { create } from "zustand";
export type Cell = {
content: string;
type: "input" | "output";
imageUrls?: string[];
};
interface JupyterState {
cells: Cell[];
appendJupyterInput: (content: string) => void;
appendJupyterOutput: (payload: {
content: string;
imageUrls?: string[];
}) => void;
clearJupyter: () => void;
}
export const useJupyterStore = create<JupyterState>((set) => ({
cells: [],
appendJupyterInput: (content: string) =>
set((state) => ({
cells: [...state.cells, { content, type: "input" }],
})),
appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) =>
set((state) => ({
cells: [
...state.cells,
{
content: payload.content,
type: "output",
imageUrls: payload.imageUrls,
},
],
})),
clearJupyter: () =>
set(() => ({
cells: [],
})),
}));

View File

@@ -1,18 +0,0 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import agentReducer from "./state/agent-slice";
import { jupyterReducer } from "./state/jupyter-slice";
export const rootReducer = combineReducers({
agent: agentReducer,
jupyter: jupyterReducer,
});
const store = configureStore({
reducer: rootReducer,
});
export type RootState = ReturnType<typeof store.getState>;
export type AppStore = typeof store;
export type AppDispatch = typeof store.dispatch;
export default store;

View File

@@ -0,0 +1,21 @@
import { create } from "zustand";
import { AgentState } from "#/types/agent-state";
interface AgentStateData {
curAgentState: AgentState;
}
interface AgentStore extends AgentStateData {
setCurrentAgentState: (state: AgentState) => void;
reset: () => void;
}
const initialState: AgentStateData = {
curAgentState: AgentState.LOADING,
};
export const useAgentStore = create<AgentStore>((set) => ({
...initialState,
setCurrentAgentState: (state: AgentState) => set({ curAgentState: state }),
reset: () => set(initialState),
}));

View File

@@ -1,15 +1,12 @@
// See https://redux.js.org/usage/writing-tests#setting-up-a-reusable-test-render-function for more information
// Test utilities for React components
import React, { PropsWithChildren } from "react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { RenderOptions, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { vi } from "vitest";
import { AxiosError } from "axios";
import { AppStore, RootState, rootReducer } from "./src/store";
// Mock useParams before importing components
vi.mock("react-router", async () => {
@@ -37,53 +34,14 @@ i18n.use(initReactI18next).init({
},
});
export const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
reducer: rootReducer,
preloadedState,
});
// This type interface extends the default options for render from RTL
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {}
// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
preloadedState?: Partial<RootState>;
store?: AppStore;
}
// Export our own customized renderWithProviders function that creates a new Redux store and renders a <Provider>
// Note that this creates a separate Redux store instance for every test, rather than reusing the same store instance and resetting its state
// Export our own customized renderWithProviders function that renders with QueryClient and i18next providers
// Since we're using Zustand stores, we don't need a Redux Provider wrapper
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</QueryClientProvider>
</Provider>
);
}
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
// Export a render function for components that only need QueryClient and i18next providers
// (without Redux store)
export function renderWithQueryAndI18n(
ui: React.ReactElement,
renderOptions: Omit<RenderOptions, "wrapper"> = {},
renderOptions: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: PropsWithChildren) {
return (

View File

@@ -35,11 +35,9 @@ export default defineConfig(({ mode }) => {
include: [
// Pre-bundle ALL dependencies to prevent runtime optimization and page reloads
// These are discovered during initial app load:
"react-redux",
"posthog-js",
"@tanstack/react-query",
"react-hot-toast",
"@reduxjs/toolkit",
"i18next",
"i18next-http-backend",
"i18next-browser-languagedetector",
@@ -62,7 +60,7 @@ export default defineConfig(({ mode }) => {
"react-icons/lu",
"react-icons/di",
"react-icons/io5",
"react-icons/io", // Added to prevent runtime optimization
"react-icons/io", // Added to prevent runtime optimization
"@monaco-editor/react",
"react-textarea-autosize",
"react-markdown",

View File

@@ -1,52 +0,0 @@
# 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

View File

@@ -1,46 +0,0 @@
.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-cli
# 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

View File

@@ -1,45 +0,0 @@
# OpenHands CLI
A lightweight CLI/TUI to interact with the OpenHands agent (powered by agent-sdk). Build and run locally or as a single executable.
## 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
# Install dependencies (incl. dev tools)
make install-dev
# Optional: install pre-commit hooks
make install-pre-commit-hooks
# Start the CLI
make run
# or
uv run openhands-cli
```
Tip: Set your model key (one of) so the agent can talk to an LLM:
```bash
export OPENAI_API_KEY=...
# or
export LITELLM_API_KEY=...
```
### Build a standalone executable
```bash
# Build (installs PyInstaller if needed)
./build.sh --install-pyinstaller
# The binary will be in dist/
./dist/openhands-cli # macOS/Linux
# dist/openhands-cli.exe # Windows
```
For advanced development (adding deps, updating the spec file, debugging builds), see Development.md.

View File

@@ -1,281 +0,0 @@
#!/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 shutil
import subprocess
import sys
from pathlib import Path
from openhands_cli.locations import PERSISTENCE_DIR, WORK_DIR, AGENT_SETTINGS_PATH
from openhands.sdk.preset.default import get_default_agent
from openhands.sdk import LLM
import time
import select
dummy_agent = get_default_agent(
llm=LLM(model='dummy-model', api_key='dummy-key'),
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-cli.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-cli')
if not exe_path.exists():
exe_path = Path('dist/openhands-cli.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-cli.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())

View File

@@ -1,48 +0,0 @@
#!/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[@]}"

View File

@@ -1,63 +0,0 @@
import atexit, os, sys, 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")

View File

@@ -1,110 +0,0 @@
# -*- 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-cli',
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
)

View File

@@ -1,3 +0,0 @@
"""OpenHands CLI package."""
__version__ = "0.1.0"

View File

@@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""
Agent chat functionality for OpenHands CLI.
Provides a conversation interface with an AI agent using OpenHands patterns.
"""
from functools import partial
import sys
import uuid
from openhands.sdk import (
Message,
TextContent,
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands_cli.tui.settings.mcp_screen import MCPScreen
from openhands_cli.user_actions.utils import get_session_prompter
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.runner import ConversationRunner
from openhands_cli.setup import setup_conversation, MissingAgentSpec
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
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 create_callable(conversation):
def get_conversation():
return conversation
return get_conversation
def make_conversation_from_id(conversation_id: uuid.UUID):
return setup_conversation(conversation_id)
def run_cli_entry() -> 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()
conversation_id = uuid.uuid4()
# while not conversation:
# try:
# conversation = setup_conversation(conversation_id)
# except MissingAgentSpec:
# settings_screen.handle_basic_settings(escapable=False)
display_welcome(conversation_id)
# Create conversation runner to handle state machine logic
factory = partial(make_conversation_from_id, conversation_id)
runner = ConversationRunner(factory)
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>"))
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>"))
break
# Clean up terminal state
_restore_tty()

View File

@@ -1,7 +0,0 @@
from openhands_cli.listeners.pause_listener import PauseListener
from openhands_cli.listeners.loading_listener import LoadingContext
__all__ = [
"PauseListener",
"LoadingContext"
]

View File

@@ -1,61 +0,0 @@
"""
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

View File

@@ -1,125 +0,0 @@
import threading
import time
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from openhands.sdk import Conversation
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.input import Input, create_input
from prompt_toolkit.keys import Keys
class PauseListener(threading.Thread):
"""Background key listener that triggers pause on Ctrl-P and immediate termination on double Ctrl-C.
Starts and stops around agent run() loops to avoid interfering with user prompts.
"""
def __init__(
self,
on_pause: Callable,
on_terminate: Callable | None = None, # called on double Ctrl+C
input_source: Input | None = None, # used to pipe inputs for unit tests
):
super().__init__(daemon=True)
self.on_pause = on_pause
self.on_terminate = on_terminate
self._stop_event = threading.Event()
self._pause_event = threading.Event()
self._terminate_event = threading.Event()
self._input = input_source or create_input()
self.interrupt_count = 0
def _detect_pause_key_presses(self) -> tuple[bool, bool]:
"""Detect pause key presses and double Ctrl+C.
Returns:
tuple: (pause_detected, terminate_detected)
"""
pause_detected = False
terminate_detected = False
for key_press in self._input.read_keys():
if key_press.key == Keys.ControlC:
self.interrupt_count += 1
pause_detected = True
if key_press.key == Keys.ControlP or key_press.key == Keys.ControlD:
pause_detected = True
if self.interrupt_count >= 2:
terminate_detected = True
return pause_detected, terminate_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... (Press Ctrl+C again to terminate immediately)</gold>")
)
try:
self.on_pause()
except Exception:
pass
def _execute_terminate(self) -> None:
self._terminate_event.set() # Mark terminate event occurred
print_formatted_text(HTML(""))
print_formatted_text(
HTML("<red>Terminating agent immediately...</red>")
)
try:
if self.on_terminate:
self.on_terminate()
except Exception:
pass
def run(self) -> None:
try:
with self._input.raw_mode():
# User hasn't paused/terminated and pause listener hasn't been shut down
while not (self.is_paused() or self.is_terminated() or self.is_stopped()):
pause_detected, terminate_detected = self._detect_pause_key_presses()
if terminate_detected:
self._execute_terminate()
break
elif pause_detected:
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()
def is_terminated(self) -> bool:
return self._terminate_event.is_set()
@contextmanager
def pause_listener(
conversation: Conversation,
on_terminate: Callable | None = None,
input_source: Input | None = None
) -> Iterator[PauseListener]:
"""Ensure PauseListener always starts/stops cleanly."""
listener = PauseListener(
on_pause=conversation.pause,
on_terminate=on_terminate,
input_source=input_source
)
listener.start()
try:
yield listener
finally:
listener.stop()

View File

@@ -1,16 +0,0 @@
import os
from uuid import UUID
# Configuration directory for storing agent settings and CLI configuration
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
# 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"
def get_conversation_perisistence_path(conversation_id: UUID):
return os.path.join(PERSISTENCE_DIR, f"conversation/{conversation_id}")

View File

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

View File

@@ -1,198 +0,0 @@
from typing import Callable
from openhands.sdk import BaseConversation, Message
from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
NeverConfirm,
ConfirmRisky,
ConfirmationPolicyBase
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.event.utils import get_unmatched_actions
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
from openhands_cli.threaded_agent import ProcessAgentRunner
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, get_conversation: Callable):
self._runner = ProcessAgentRunner(get_conversation)
self.conversation = get_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, Ctrl-C twice to terminate)</grey>"
)
)
else:
print_formatted_text(
HTML(
"<yellow>Agent running...</yellow><grey> (Press Ctrl-P to pause, Ctrl-C twice to terminate)</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:
# Start the agent in a separate thread
self._runner.run_agent()
# Set up pause listener with termination callback
with pause_listener(
self.conversation,
on_terminate=self._runner.terminate_immediately
) as listener:
# Wait for agent to complete or be terminated
try:
self._runner.wait_for_completion()
except Exception as e:
if not listener.is_terminated():
# Re-raise exception if it wasn't due to termination
raise e
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:
# Start the agent in a separate thread
self._runner.run_agent()
with pause_listener(
self.conversation,
on_terminate=self._runner.terminate_immediately
) as listener:
try:
self._runner.wait_for_completion()
except Exception as e:
if not listener.is_terminated():
# Re-raise exception if it wasn't due to termination
raise e
if listener.is_paused() or listener.is_terminated():
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 = 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

View File

@@ -1,50 +0,0 @@
import uuid
from openhands.sdk import BaseConversation, Conversation, LocalFileStore, register_tool
from openhands.tools.execute_bash import BashTool
from openhands.tools.str_replace_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.listeners import LoadingContext
from openhands_cli.locations import get_conversation_perisistence_path
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) -> BaseConversation:
"""
Setup the conversation with agent.
Raises:
MissingAgentSpec: If agent specification is not found or invalid.
"""
print("creating conversation", conversation_id)
with LoadingContext("Initializing OpenHands agent..."):
agent_store = AgentStore()
agent = agent_store.load()
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,
persist_filestore=LocalFileStore(
get_conversation_perisistence_path(conversation_id)
),
conversation_id=conversation_id
)
print_formatted_text(
HTML(f"<green>✓ Agent initialized with model: {agent.llm.model}</green>")
)
return conversation

View File

@@ -1,53 +0,0 @@
#!/usr/bin/env python3
"""
Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality.
"""
import logging
import os
debug_env = os.getenv('DEBUG', 'false').lower()
if debug_env != '1' and debug_env != 'true':
logging.disable(logging.WARNING)
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
"""
try:
# Start agent chat directly by default
run_cli_entry()
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()

View File

@@ -1,109 +0,0 @@
# process_agent_runner.py
from __future__ import annotations
import multiprocessing as mp
import os
import signal
import sys
from typing import Callable, Optional
class ProcessAgentRunner:
"""
Run conversation.run() in a *separate process* so we can terminate immediately
(even mid-step) without unsafe thread hacks.
Usage:
runner = ProcessAgentRunner(conversation_factory)
runner.run_agent()
runner.wait_for_completion()
# on double Ctrl-C:
runner.terminate_immediately()
"""
def __init__(
self,
conversation_factory: Callable[[], object], # must be picklable
start_method: Optional[str] = None, # default "spawn" cross-platform
kill_grace_seconds: float = 1.5, # SIGTERM grace before SIGKILL (POSIX)
):
self._ctx = mp.get_context(start_method or "spawn")
self._factory = conversation_factory
self._proc: Optional[mp.Process] = None
self._done = self._ctx.Event()
self._kill_grace_seconds = kill_grace_seconds
def run_agent(self) -> None:
if self._proc and self._proc.is_alive():
return
self._done.clear()
self._proc = self._ctx.Process(
target=_child_entry,
args=(self._factory, self._done),
daemon=True,
)
self._proc.start()
def wait_for_completion(self, timeout: float | None = None) -> None:
if not self._proc:
return
self._proc.join(timeout)
def is_running(self) -> bool:
return bool(self._proc and self._proc.is_alive())
def terminate_immediately(self) -> None:
"""Kill the agent process right now (and its children on POSIX)."""
if not self._proc or not self._proc.is_alive():
return
pid = self._proc.pid
if pid is None:
return
try:
if os.name == "posix":
# Kill whole process group (child calls setsid()).
pgid = os.getpgid(pid)
os.killpg(pgid, signal.SIGTERM)
self._proc.join(self._kill_grace_seconds)
if self._proc.is_alive():
os.killpg(pgid, signal.SIGKILL)
else:
# Windows: terminate the process (children may persist unless using Job Objects).
self._proc.terminate()
except Exception:
# Last resort
try:
self._proc.kill()
except Exception:
pass
def close(self) -> None:
if self._proc and self._proc.is_alive():
self.terminate_immediately()
self._proc = None
def _child_entry(conversation_factory: Callable[[], object], done_event: mp.Event) -> None:
"""Child process: build conversation and run synchronously."""
# New process group so parent can kill the entire tree on POSIX.
if os.name == "posix":
os.setsid()
try:
conv = conversation_factory()
conv.run() # blocking, synchronous; safe to kill via signals
except KeyboardInterrupt:
pass
except Exception:
# Log to child stderr so you can see failures
import traceback
traceback.print_exc()
finally:
done_event.set()
try:
sys.stdout.flush()
sys.stderr.flush()
except Exception:
pass

View File

@@ -1,5 +0,0 @@
from openhands_cli.tui.tui import DEFAULT_STYLE
__all__ = [
"DEFAULT_STYLE",
]

View File

@@ -1,202 +0,0 @@
import json
from pathlib import Path
from typing import Any
from openhands_cli.locations import MCP_CONFIG_FILE, PERSISTENCE_DIR
from openhands_cli.tui.settings.store import AgentStore
from prompt_toolkit import HTML, print_formatted_text
from fastmcp.mcp_config import MCPConfig
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)

View File

@@ -1,206 +0,0 @@
import os
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import (
SettingsType,
choose_llm_model,
choose_llm_provider,
prompt_api_key,
save_settings_confirmation,
settings_type_confirmation,
prompt_custom_model,
prompt_base_url,
choose_memory_condensation,
)
from openhands_cli.tui.utils import StepCounter
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Conversation, LLM, LocalFileStore
from openhands.sdk.preset.default import get_default_agent
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands_cli.pt_style import COLOR_GREY
class SettingsScreen:
def __init__(self, conversation: Conversation | 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
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 self.conversation.confirmation_policy_active 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.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"
)
agent = self.agent_store.load()
if not agent:
agent = get_default_agent(
llm=llm,
working_dir=WORK_DIR,
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)

View File

@@ -1,63 +0,0 @@
# openhands_cli/settings/store.py
from __future__ import annotations
from pathlib import Path
from typing import Any
from openhands.sdk import LocalFileStore, Agent
from openhands.sdk import LocalFileStore, Agent, AgentContext
from openhands.sdk.preset.default import get_default_tools
from openhands_cli.locations import AGENT_SETTINGS_PATH, MCP_CONFIG_FILE, PERSISTENCE_DIR, WORK_DIR
from prompt_toolkit import HTML, print_formatted_text
from fastmcp.mcp_config import MCPConfig
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 as e:
return {}
def load(self) -> 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(
working_dir=WORK_DIR,
persistence_dir=PERSISTENCE_DIR,
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)
agent = agent.model_copy(update={
"tools": updated_tools,
"mcp_config": {'mcpServers': mcp_config} if mcp_config else {},
"agent_context": agent_context
})
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)

View File

@@ -1,94 +0,0 @@
from collections.abc import Generator
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
from uuid import UUID
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) -> 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("")
print_formatted_text(HTML(f"<grey>Initialized 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) -> None:
"""Display welcome message."""
clear()
display_banner(str(conversation_id)[0:8])
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()

View File

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

View File

@@ -1,17 +0,0 @@
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',
]

View File

@@ -1,96 +0,0 @@
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk.security.confirmation_policy import (
ConfirmRisky,
SecurityRisk,
NeverConfirm
)
from openhands_cli.user_actions.types import UserConfirmation, ConfirmationResult
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)

View File

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

View File

@@ -1,148 +0,0 @@
from enum import Enum
from openhands_cli.tui.utils import StepCounter
from openhands_cli.user_actions.utils import NonEmptyValueValidator
from prompt_toolkit.completion import FuzzyWordCompleter
from pydantic import SecretStr
from openhands.sdk.llm import (
VERIFIED_MODELS,
UNVERIFIED_MODELS_EXCLUDING_BEDROCK
)
from openhands_cli.user_actions.utils import 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)
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)
if options[index] == discard:
raise KeyboardInterrupt
return options[index]

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