Compare commits

..

79 Commits

Author SHA1 Message Date
opencode-agent[bot] 3aff88c23d docs: add use_github_token to example (#9120)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-01-17 13:36:54 -06:00
Aiden Cline 58f7da6e9f docs: document the plural forms 2026-01-17 13:09:30 -06:00
Rahul Mishra 5a199b04cb fix: don't try to open command palette if a dialog is already open (#9116) 2026-01-17 13:08:11 -06:00
Bernat Pericàs eb968a6651 docs(config): explain that autoupdate doesn't work when installed with a package manager (#9092) 2026-01-17 13:07:03 -06:00
Colby Gilbert a813fcb41c docs: add firmware provider to providers docs (#8993) 2026-01-17 13:04:43 -06:00
GitHub Action a58d1be822 ignore: update download stats 2026-01-17 2026-01-17 12:04:18 +00:00
Slone 07dc8d8ce4 fix: escape CSS selector keys to handle special characters (#9030) 2026-01-17 05:48:38 -06:00
GitHub Action d377246491 chore: generate 2026-01-17 11:47:55 +00:00
Javier Aceña 7030f49a74 fix: mdns discover hostname (#9039) 2026-01-17 05:47:19 -06:00
Eric Guo c4e4f2a058 fix(desktop): Added a Windows-only guard that makes window.getComputedStyle fall back to document.documentElement (#9054) 2026-01-17 05:45:31 -06:00
Adam 2729705594 fix(app): archive session sometimes flaky 2026-01-17 05:23:17 -06:00
Aiden Cline ea13b6e8aa test: add azure test case 2026-01-17 00:35:49 -06:00
GitHub Action 85ab9798c6 chore: generate 2026-01-17 04:18:35 +00:00
Aiden Cline 33290c54cd Revert "feat(mcp): add OAuth redirect URI configuration for MCP servers (#7379)"
This reverts commit 40b275d7e6.
2026-01-16 22:17:33 -06:00
GitHub Action 5d613a038d chore: generate 2026-01-17 04:16:35 +00:00
ben db78a59f03 docs: Add OpenWork to ecosystem (#8741) 2026-01-16 22:15:59 -06:00
Aiden Cline 7c3eeeb0fa fix: gpt id stuff fr fr this time :/ (#9006) 2026-01-16 22:09:36 -06:00
Github Action e8357a87b0 Update node_modules hashes 2026-01-17 02:51:01 +00:00
Jérôme Benoit 06c543e938 fix(nix): resolve hash race condition in parallel matrix jobs (#8995) 2026-01-16 20:26:08 -06:00
David Hill 759ce8fb8e fix: prevent text clipping on search button descenders 2026-01-17 01:06:53 +00:00
David Hill 38847e13bb fix: truncate long search queries in empty state 2026-01-17 00:55:13 +00:00
David Hill e0c6459faa fix: remove smooth scroll behavior from list component 2026-01-17 00:55:13 +00:00
David Hill 80b278ddab fix: remove the secondary text from commands 2026-01-17 00:55:13 +00:00
David Hill ef7ef6538e fix: limit search modal max-height to 480px 2026-01-17 00:55:13 +00:00
David Hill d23c21023a fix: refine search modal styling and list component 2026-01-17 00:55:13 +00:00
David Hill dfa2a9f225 fix: reduce command item left padding in search modal 2026-01-17 00:55:13 +00:00
David Hill 6f78a71fa7 feat: add hideIcon and class options to List search, customize search modal input 2026-01-17 00:55:13 +00:00
David Hill f8f1f46a4f fix: adjust command item left padding in search modal 2026-01-17 00:55:13 +00:00
David Hill ab705dacfa fix: add left padding to command items in search modal 2026-01-17 00:55:13 +00:00
David Hill d1b93616f7 fix: increase keybind border-radius in search modal 2026-01-17 00:55:13 +00:00
David Hill 69215d456c fix: display arrow keys as symbols in keybind formatting 2026-01-17 00:55:13 +00:00
David Hill 54e52896a4 refactor: use Keybind component in search modal list 2026-01-17 00:55:13 +00:00
David Hill b18fb16e9c refactor: use Keybind component in titlebar search button 2026-01-17 00:55:13 +00:00
David Hill 1250486ddf feat: add Keybind component for displaying keyboard shortcuts 2026-01-17 00:55:13 +00:00
David Hill d645e8bbe1 fix: (desktop) command palette width 2026-01-17 00:55:13 +00:00
David Hill cad415872e fix: recent sessions gutter 2026-01-17 00:55:13 +00:00
Frank e8746ddb1d zen: fix opus unicode characters
closes #8967
2026-01-16 18:52:08 -05:00
GitHub Action 80020ade2e chore: generate 2026-01-16 23:23:57 +00:00
Amir Hasanbasic 08ef97b162 fix(opencode): add oauth polling safety margin in copilot device authentication (#8986) 2026-01-16 17:23:18 -06:00
Github Action 1aedb265dd Update node_modules hash (aarch64-darwin) 2026-01-16 23:16:53 +00:00
Github Action 5c13b209aa Update node_modules hash (x86_64-darwin) 2026-01-16 23:11:48 +00:00
Github Action 43a9c50389 Update node_modules hash (x86_64-linux) 2026-01-16 23:02:26 +00:00
Github Action 55224d64a2 Update flake.lock 2026-01-16 23:01:30 +00:00
Daniel Polito c325aa1142 fix(desktop): Stream bash output + strip-asni (#8961) 2026-01-16 17:00:56 -06:00
Caleb Norton 6e020ef9ef chore: cleanup nix (#8964) 2026-01-16 16:59:34 -06:00
Caleb Norton aca1eb6b5b fix(nix): add desktop application entry (#8972) 2026-01-16 16:59:07 -06:00
b3nw 3d095e7fe7 fix: centralize OSC 52 clipboard support for SSH sessions (#8974) 2026-01-16 16:57:17 -06:00
GitHub Action 632f20558a chore: generate 2026-01-16 22:49:19 +00:00
Frank f96c4badd8 wip: black 2026-01-16 17:48:26 -05:00
Frank cbe1c81470 wip: black 2026-01-16 17:46:36 -05:00
Akshar Patel c25155586c fix: open help dialog with tui/open-help route (#8596) 2026-01-16 16:42:27 -06:00
Seth Carlton 08b94a6890 fix: keep primary model after subagent runs (#8951) 2026-01-16 16:19:17 -06:00
GitHub Action 8cddc9ea55 chore: generate 2026-01-16 22:14:23 +00:00
Aiden Cline 578239e0d0 chore: cleanup transform code a tad 2026-01-16 16:13:38 -06:00
Ariane Emory 626fa1462b fix: make home/end keys work in menu list modal windows (resolves #7190) (#8347) 2026-01-16 21:57:59 +00:00
opencode 968239bb76 release: v1.1.25 2026-01-16 21:57:58 +00:00
Aiden Cline 8c24879246 test: fix 2026-01-16 15:52:51 -06:00
Aiden Cline 9127055ae7 tweak: wording 2026-01-16 15:50:24 -06:00
Aiden Cline f5a6a4af7f Revert "fix: ensure that tool attachments arent sent as user messages (#8944)"
This reverts commit 8fd1b92e6e.
2026-01-16 15:50:24 -06:00
Adam 6e00348bd7 fix(app): remember last opened project 2026-01-16 15:49:35 -06:00
Adam 95f7403daf fix(app): truncate workspace title 2026-01-16 15:49:35 -06:00
Aiden Cline 14d1e20287 Revert "fix(app): support anthropic models on azure cognitive services" (#8966) 2026-01-16 15:25:23 -06:00
Unies Ananda Raja b8e2895dfc fix(app): support anthropic models on azure cognitive services (#8335) 2026-01-16 15:24:06 -06:00
GitHub Action 6e028ec2dc chore: generate 2026-01-16 21:21:54 +00:00
Aiden Cline 8e0ddd1ac9 chore: cleanup server routes (#8965)
Co-authored-by: Leka74 <leke.dobruna@gmail.com>
Co-authored-by: Leka74 <791494+Leka74@users.noreply.github.com>
2026-01-16 15:21:13 -06:00
Adam da78b758d4 fix(app): handle new session correctly 2026-01-16 14:49:04 -06:00
Adam 360765c591 fix(app): center dialog on page instead of session 2026-01-16 14:33:32 -06:00
GitHub Action db0078bf17 chore: generate 2026-01-16 20:32:44 +00:00
kenryu42 98578d3a7b fix(bun): reinstall plugins when cache module missing (#8815) 2026-01-16 14:32:05 -06:00
opencode bc3616d9c6 release: v1.1.24 2026-01-16 20:15:19 +00:00
Adam 71306cbd1f Revert "feat(desktop): Terminal Splits (#8767)"
This reverts commit 88fd6a294b.
2026-01-16 14:03:13 -06:00
Adam 0866034946 feat(app): edit project and session titles 2026-01-16 13:55:59 -06:00
Adam 2ccaa10e79 fix(app): open workspace if navigating to session in workspace 2026-01-16 13:24:47 -06:00
Adam e92d5b592c fix(app): can't expand workspaces 2026-01-16 13:24:47 -06:00
Adam 00ec29dae6 fix(app): scroll jumping when expanding workspaces 2026-01-16 13:24:47 -06:00
Github Action 438916de5f Update node_modules hash (x86_64-darwin) 2026-01-16 18:58:41 +00:00
Github Action 8d4a67324e Update node_modules hash (aarch64-linux) 2026-01-16 18:53:19 +00:00
Sebastian Herrlinger 0d683eaa8e upgrade opentui to v0.1.74, fix tmux kitty keyboard regression 2026-01-16 19:52:05 +01:00
Aiden Cline 8fd1b92e6e fix: ensure that tool attachments arent sent as user messages (#8944) 2026-01-16 12:47:43 -06:00
116 changed files with 8083 additions and 5584 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ on:
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
@@ -16,6 +17,7 @@ on:
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
@@ -26,7 +28,7 @@ jobs:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
+170 -32
View File
@@ -10,11 +10,13 @@ on:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- ".github/workflows/update-nix-hashes.yml"
pull_request:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- ".github/workflows/update-nix-hashes.yml"
jobs:
update-flake:
@@ -25,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
@@ -43,9 +45,9 @@ jobs:
- name: Update ${{ env.TITLE }}
run: |
set -euo pipefail
echo "📦 Updating $TITLE..."
echo "Updating $TITLE..."
nix flake update
echo "✅ $TITLE updated successfully"
echo "$TITLE updated successfully"
- name: Commit ${{ env.TITLE }} changes
env:
@@ -53,7 +55,7 @@ jobs:
run: |
set -euo pipefail
echo "🔍 Checking for changes in tracked files..."
echo "Checking for changes in tracked files..."
summarize() {
local status="$1"
@@ -71,29 +73,29 @@ jobs:
FILES=(flake.lock flake.nix)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "✅ No changes detected."
echo "No changes detected."
summarize "no changes"
exit 0
fi
echo "📝 Changes detected:"
echo "Changes detected:"
echo "$STATUS"
echo "🔗 Staging files..."
echo "Staging files..."
git add "${FILES[@]}"
echo "💾 Committing changes..."
echo "Committing changes..."
git commit -m "Update $TITLE"
echo "✅ Changes committed"
echo "Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
echo "🌳 Pulling latest from branch: $BRANCH"
git pull --rebase origin "$BRANCH"
echo "🚀 Pushing changes to branch: $BRANCH"
echo "Pulling latest from branch: $BRANCH"
git pull --rebase --autostash origin "$BRANCH"
echo "Pushing changes to branch: $BRANCH"
git push origin HEAD:"$BRANCH"
echo "✅ Changes pushed successfully"
echo "Changes pushed successfully"
summarize "committed $(git rev-parse --short HEAD)"
update-node-modules-hash:
compute-node-modules-hash:
needs: update-flake
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
@@ -111,11 +113,10 @@ jobs:
runs-on: ${{ matrix.host }}
env:
SYSTEM: ${{ matrix.system }}
TITLE: node_modules hash (${{ matrix.system }})
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
@@ -125,6 +126,104 @@ jobs:
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Compute node_modules hash
run: |
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
HASH_FILE="nix/hashes.json"
OUTPUT_FILE="hash-${SYSTEM}.txt"
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
BUILD_LOG=$(mktemp)
TMP_JSON=$(mktemp)
trap 'rm -f "$BUILD_LOG" "$TMP_JSON"' EXIT
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
# Set dummy hash to force nix to rebuild and reveal correct hash
jq --arg system "$SYSTEM" --arg value "$DUMMY" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$system] = $value' "$HASH_FILE" > "$TMP_JSON"
mv "$TMP_JSON" "$HASH_FILE"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Building node_modules for ${SYSTEM} to discover correct hash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
CORRECT_HASH=""
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
# Try to extract hash from build log
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
# Try to hash from kept failed build directory
if [ -z "$CORRECT_HASH" ]; then
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1 || true)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1 || true)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
HASH_PATH="$KEPT_DIR"
[ -d "$KEPT_DIR/build" ] && HASH_PATH="$KEPT_DIR/build"
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
fi
echo "$CORRECT_HASH" > "$OUTPUT_FILE"
echo "Hash for ${SYSTEM}: $CORRECT_HASH"
- name: Upload hash artifact
uses: actions/upload-artifact@v6
with:
name: hash-${{ matrix.system }}
path: hash-${{ matrix.system }}.txt
retention-days: 1
commit-node-modules-hashes:
needs: compute-node-modules-hash
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: node_modules hashes
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Configure git
run: |
git config --global user.email "action@github.com"
@@ -135,14 +234,57 @@ jobs:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull origin "$BRANCH"
git pull --rebase --autostash origin "$BRANCH"
- name: Update ${{ env.TITLE }}
- name: Download all hash artifacts
uses: actions/download-artifact@v7
with:
pattern: hash-*
merge-multiple: true
- name: Merge hashes into hashes.json
run: |
set -euo pipefail
echo "🔄 Updating $TITLE..."
nix/scripts/update-hashes.sh
echo "✅ $TITLE updated successfully"
HASH_FILE="nix/hashes.json"
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
echo "Merging hashes into ${HASH_FILE}..."
shopt -s nullglob
files=(hash-*.txt)
if [ ${#files[@]} -eq 0 ]; then
echo "No hash files found, nothing to update"
exit 0
fi
EXPECTED_SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
for sys in $EXPECTED_SYSTEMS; do
if [ ! -f "hash-${sys}.txt" ]; then
echo "WARNING: Missing hash file for $sys"
fi
done
for f in "${files[@]}"; do
system="${f#hash-}"
system="${system%.txt}"
hash=$(cat "$f")
if [ -z "$hash" ]; then
echo "WARNING: Empty hash for $system, skipping"
continue
fi
echo " $system: $hash"
jq --arg sys "$system" --arg h "$hash" \
'.nodeModules = (.nodeModules // {}) | .nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
mv "${HASH_FILE}.tmp" "$HASH_FILE"
done
echo "All hashes merged:"
cat "$HASH_FILE"
- name: Commit ${{ env.TITLE }} changes
env:
@@ -150,7 +292,8 @@ jobs:
run: |
set -euo pipefail
echo "🔍 Checking for changes in tracked files..."
HASH_FILE="nix/hashes.json"
echo "Checking for changes..."
summarize() {
local status="$1"
@@ -166,27 +309,22 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(nix/hashes.json)
FILES=("$HASH_FILE")
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "✅ No changes detected."
echo "No changes detected."
summarize "no changes"
exit 0
fi
echo "📝 Changes detected:"
echo "Changes detected:"
echo "$STATUS"
echo "🔗 Staging files..."
git add "${FILES[@]}"
echo "💾 Committing changes..."
git commit -m "Update $TITLE"
echo "✅ Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
echo "🌳 Pulling latest from branch: $BRANCH"
git pull --rebase origin "$BRANCH"
echo "🚀 Pushing changes to branch: $BRANCH"
git pull --rebase --autostash origin "$BRANCH"
git push origin HEAD:"$BRANCH"
echo "✅ Changes pushed successfully"
echo "Changes pushed successfully"
summarize "committed $(git rev-parse --short HEAD)"
+1
View File
@@ -20,6 +20,7 @@ opencode.json
a.out
target
.scripts
.direnv/
# Local dev files
opencode-dev
+1
View File
@@ -202,3 +202,4 @@
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
| 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) |
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
+26 -25
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -104,7 +104,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -131,7 +131,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -155,7 +155,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -179,7 +179,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -208,7 +208,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -237,7 +237,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -253,7 +253,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.23",
"version": "1.1.25",
"bin": {
"opencode": "./bin/opencode",
},
@@ -293,8 +293,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -357,7 +357,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -377,7 +377,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.23",
"version": "1.1.25",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@tsconfig/node22": "catalog:",
@@ -388,7 +388,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -401,7 +401,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -424,6 +424,7 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
"virtua": "catalog:",
},
"devDependencies": {
@@ -441,7 +442,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"zod": "catalog:",
},
@@ -452,7 +453,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1218,21 +1219,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="],
"@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="],
"@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="],
"@opentui/solid": ["@opentui/solid@0.1.74", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.74", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Vz82cI8T9YeJjGsVg4ULp6ral4N+xyt1j9A6Tbu3aaQgEKiB74LW03EXREehfjPr1irOFxtKfWPbx5NKH0Upag=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"lastModified": 1768456270,
"narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"rev": "f4606b01b39e09065df37905a2133905246db9ed",
"type": "github"
},
"original": {
+3 -25
View File
@@ -7,6 +7,7 @@
outputs =
{
self,
nixpkgs,
...
}:
@@ -107,33 +108,10 @@
};
in
{
default = opencodePkg;
default = self.packages.${system}.opencode;
opencode = opencodePkg;
desktop = desktopPkg;
}
);
apps = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
opencode-dev = {
type = "app";
meta = {
description = "Nix devshell shell for OpenCode";
runtimeInputs = [ pkgs.bun ];
};
program = "${
pkgs.writeShellApplication {
name = "opencode-dev";
text = ''
exec bun run dev "$@"
'';
}
}/bin/opencode-dev";
};
}
);
};
}
+2
View File
@@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
model: anthropic/claude-sonnet-4-20250514
use_github_token: true
```
3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys.
+22
View File
@@ -15,6 +15,8 @@
cargo,
rustc,
makeBinaryWrapper,
copyDesktopItems,
makeDesktopItem,
nodejs,
jq,
}:
@@ -57,12 +59,28 @@ rustPlatform.buildRustPackage rec {
pkg-config
bun
makeBinaryWrapper
copyDesktopItems
cargo
rustc
nodejs
jq
];
# based on packages/desktop/src-tauri/release/appstream.metainfo.xml
desktopItems = lib.optionals stdenv.isLinux [
(makeDesktopItem {
name = "ai.opencode.opencode";
desktopName = "OpenCode";
comment = "Open source AI coding agent";
exec = "opencode-desktop";
icon = "opencode";
terminal = false;
type = "Application";
categories = [ "Development" "IDE" ];
startupWMClass = "opencode";
})
];
buildInputs = [
openssl
]
@@ -121,6 +139,10 @@ rustPlatform.buildRustPackage rec {
# It looks for them in the location specified in tauri.conf.json.
postInstall = lib.optionalString stdenv.isLinux ''
# Install icon
mkdir -p $out/share/icons/hicolor/128x128/apps
cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png
# Wrap the binary to ensure it finds the libraries
wrapProgram $out/bin/opencode-desktop \
--prefix LD_LIBRARY_PATH : ${
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-07XxcHLuToM4QfWVyaPLACxjPZ93ZM7gtpX2o08Lp18=",
"aarch64-linux": "sha256-E6lyYFApS1cw3jE7ISx5QZxDDJ9V3HU0ICYFdY+aIBw=",
"aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=",
"x86_64-darwin": "sha256-grPR/YBqYPEUBks4nQKYe6/9f+9N0Fk9l2L9J6ylWkc="
"x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=",
"aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=",
"aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=",
"x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE="
}
}
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
SYSTEM=${SYSTEM:-x86_64-linux}
DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": {}
}
EOF
fi
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
git add -N "$HASH_FILE" >/dev/null 2>&1 || true
fi
fi
export DUMMY
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
cleanup() {
rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
}
trap cleanup EXIT
write_node_modules_hash() {
local value="$1"
local system="${2:-$SYSTEM}"
local temp
temp=$(mktemp)
if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then
jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp"
else
jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp"
fi
mv "$temp" "$HASH_FILE"
}
TARGET="packages.${SYSTEM}.default"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
CORRECT_HASH=""
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
write_node_modules_hash "$DUMMY"
BUILD_LOG=$(mktemp)
JSON_OUTPUT=$(mktemp)
echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Searching for kept failed build directory..."
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
echo "Found kept build directory: $KEPT_DIR"
if [ -d "$KEPT_DIR/build" ]; then
HASH_PATH="$KEPT_DIR/build"
else
HASH_PATH="$KEPT_DIR"
fi
echo "Attempting to hash: $HASH_PATH"
ls -la "$HASH_PATH" || true
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
echo "Computed hash from kept build: $CORRECT_HASH"
fi
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
echo "Build log:"
cat "$BUILD_LOG"
exit 1
fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
rm -f "$BUILD_LOG"
unset BUILD_LOG
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.23",
"version": "1.1.25",
"description": "",
"type": "module",
"exports": {
@@ -1,6 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
@@ -133,14 +134,14 @@ export function DialogSelectFile() {
})
return (
<Dialog title="Search">
<Dialog class="pt-3 pb-0 !max-h-[480px]">
<List
search={{ placeholder: "Search files and commands", autofocus: true }}
search={{ placeholder: "Search files and commands", autofocus: true, hideIcon: true, class: "pl-3 pr-2 !mb-0" }}
emptyMessage="No results found"
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
groupBy={(item) => (grouped() ? item.category : "")}
groupBy={(item) => item.category}
onMove={handleMove}
onSelect={handleSelect}
>
@@ -161,7 +162,7 @@ export function DialogSelectFile() {
</div>
}
>
<div class="w-full flex items-center justify-between gap-4">
<div class="w-full flex items-center justify-between gap-4 pl-1">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
@@ -169,7 +170,7 @@ export function DialogSelectFile() {
</Show>
</div>
<Show when={item.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(item.keybind ?? "")}</span>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
</Show>
@@ -16,6 +16,7 @@ import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
@@ -59,21 +60,12 @@ export function SessionHeader() {
>
<div class="flex items-center gap-2">
<Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate" style={{ "line-height": 1 }}>
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-3.5 flex items-center overflow-visible">
Search {name()}
</span>
</div>
<Show when={hotkey()}>
{(keybind) => (
<span
class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] bg-surface-base text-12-medium text-text-weak"
style={{ "box-shadow": "var(--shadow-xxs-border)" }}
>
{keybind()}
</span>
)}
</Show>
<Show when={hotkey()}>{(keybind) => <Keybind>{keybind()}</Keybind>}</Show>
</button>
</Portal>
)}
@@ -14,8 +14,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element
<Tabs.Trigger
value={props.terminal.id}
closeButton={
terminal.tabs().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} />
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
@@ -1,322 +0,0 @@
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
import { Terminal } from "./terminal"
import { useTerminal, type Panel } from "@/context/terminal"
import { IconButton } from "@opencode-ai/ui/icon-button"
export interface TerminalSplitProps {
tabId: string
}
function computeLayout(
panels: Record<string, Panel>,
panelId: string,
bounds: { top: number; left: number; width: number; height: number },
): Map<string, { top: number; left: number; width: number; height: number }> {
const result = new Map<string, { top: number; left: number; width: number; height: number }>()
const panel = panels[panelId]
if (!panel) return result
if (panel.ptyId) {
result.set(panel.ptyId, bounds)
} else if (panel.children && panel.children.length === 2) {
const [leftId, rightId] = panel.children
const sizes = panel.sizes ?? [50, 50]
if (panel.direction === "horizontal") {
const topHeight = (bounds.height * sizes[0]) / 100
const topBounds = { ...bounds, height: topHeight }
const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bounds.height - topHeight }
for (const [k, v] of computeLayout(panels, leftId, topBounds)) result.set(k, v)
for (const [k, v] of computeLayout(panels, rightId, bottomBounds)) result.set(k, v)
} else {
const leftWidth = (bounds.width * sizes[0]) / 100
const leftBounds = { ...bounds, width: leftWidth }
const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: bounds.width - leftWidth }
for (const [k, v] of computeLayout(panels, leftId, leftBounds)) result.set(k, v)
for (const [k, v] of computeLayout(panels, rightId, rightBounds)) result.set(k, v)
}
}
return result
}
function findPanelForPty(panels: Record<string, Panel>, ptyId: string): string | undefined {
for (const [id, panel] of Object.entries(panels)) {
if (panel.ptyId === ptyId) return id
}
}
export function TerminalSplit(props: TerminalSplitProps) {
const terminal = useTerminal()
const pane = createMemo(() => terminal.pane(props.tabId))
const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId))
const [containerFocused, setContainerFocused] = createSignal(true)
const layout = createMemo(() => {
const p = pane()
if (!p) {
const single = terminals()[0]
if (!single) return new Map()
return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]])
}
return computeLayout(p.panels, p.root, { top: 0, left: 0, width: 100, height: 100 })
})
const focused = createMemo(() => {
const p = pane()
if (!p) return props.tabId
const focusedPanel = p.panels[p.focused ?? ""]
return focusedPanel?.ptyId ?? props.tabId
})
const handleFocus = (ptyId: string) => {
const p = pane()
if (!p) return
const panelId = findPanelForPty(p.panels, ptyId)
if (panelId) terminal.focus(props.tabId, panelId)
}
const handleClose = (ptyId: string) => {
const pty = terminal.all().find((t) => t.id === ptyId)
if (!pty) return
const p = pane()
if (!p) {
if (pty.tabId === props.tabId) {
terminal.closeTab(props.tabId)
}
return
}
const panelId = findPanelForPty(p.panels, ptyId)
if (panelId) terminal.closeSplit(props.tabId, panelId)
}
return (
<div
class="relative size-full"
data-terminal-split-container
onFocusIn={() => setContainerFocused(true)}
onFocusOut={(e) => {
const related = e.relatedTarget as Node | null
if (!related || !e.currentTarget.contains(related)) {
setContainerFocused(false)
}
}}
>
<For each={terminals()}>
{(pty) => {
const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 })
const isFocused = createMemo(() => focused() === pty.id)
const hasSplits = createMemo(() => !!pane())
return (
<div
class="absolute flex flex-col min-h-0"
classList={{
"ring-1 ring-inset ring-border-strong-base": containerFocused() && isFocused(),
"border-l border-border-weak-base": bounds().left > 0,
"border-t border-border-weak-base": bounds().top > 0,
}}
style={{
top: `${bounds().top}%`,
left: `${bounds().left}%`,
width: `${bounds().width}%`,
height: `${bounds().height}%`,
}}
onClick={() => handleFocus(pty.id)}
>
<Show when={pane()}>
<div class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity">
<IconButton
icon="close"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
handleClose(pty.id)
}}
/>
</div>
</Show>
<div
class="flex-1 min-h-0"
classList={{ "opacity-50": !containerFocused() || (hasSplits() && !isFocused()) }}
>
<Terminal
pty={pty}
focused={isFocused()}
onCleanup={terminal.update}
onConnectError={() => terminal.clone(pty.id)}
onExit={() => handleClose(pty.id)}
class="size-full"
/>
</div>
</div>
)
}}
</For>
<ResizeHandles tabId={props.tabId} />
</div>
)
}
function ResizeHandles(props: { tabId: string }) {
const terminal = useTerminal()
const pane = createMemo(() => terminal.pane(props.tabId))
const splits = createMemo(() => {
const p = pane()
if (!p) return []
return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2)
})
return <For each={splits()}>{(panel) => <ResizeHandle tabId={props.tabId} panelId={panel.id} />}</For>
}
function ResizeHandle(props: { tabId: string; panelId: string }) {
const terminal = useTerminal()
const pane = createMemo(() => terminal.pane(props.tabId))
const panel = createMemo(() => pane()?.panels[props.panelId])
let cleanup: VoidFunction | undefined
onCleanup(() => cleanup?.())
const position = createMemo(() => {
const p = pane()
if (!p) return null
const pan = panel()
if (!pan?.children || pan.children.length !== 2) return null
const bounds = computePanelBounds(p.panels, p.root, props.panelId, {
top: 0,
left: 0,
width: 100,
height: 100,
})
if (!bounds) return null
const sizes = pan.sizes ?? [50, 50]
if (pan.direction === "horizontal") {
return {
horizontal: true,
top: bounds.top + (bounds.height * sizes[0]) / 100,
left: bounds.left,
size: bounds.width,
}
}
return {
horizontal: false,
top: bounds.top,
left: bounds.left + (bounds.width * sizes[0]) / 100,
size: bounds.height,
}
})
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
const pos = position()
if (!pos) return
const container = (e.target as HTMLElement).closest("[data-terminal-split-container]") as HTMLElement
if (!container) return
const rect = container.getBoundingClientRect()
const pan = panel()
if (!pan) return
const p = pane()
if (!p) return
const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, {
top: 0,
left: 0,
width: 100,
height: 100,
})
if (!panelBounds) return
const handleMouseMove = (e: MouseEvent) => {
if (pan.direction === "horizontal") {
const totalPx = (rect.height * panelBounds.height) / 100
const topPx = (rect.height * panelBounds.top) / 100
const posPx = e.clientY - rect.top - topPx
const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
} else {
const totalPx = (rect.width * panelBounds.width) / 100
const leftPx = (rect.width * panelBounds.left) / 100
const posPx = e.clientX - rect.left - leftPx
const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
}
}
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
cleanup = undefined
}
cleanup = handleMouseUp
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
}
return (
<Show when={position()}>
{(pos) => (
<div
data-component="resize-handle"
data-direction={pos().horizontal ? "vertical" : "horizontal"}
class="absolute"
style={{
top: `${pos().top}%`,
left: `${pos().left}%`,
width: pos().horizontal ? `${pos().size}%` : "8px",
height: pos().horizontal ? "8px" : `${pos().size}%`,
transform: pos().horizontal ? "translateY(-50%)" : "translateX(-50%)",
cursor: pos().horizontal ? "row-resize" : "col-resize",
}}
onMouseDown={handleMouseDown}
/>
)}
</Show>
)
}
function computePanelBounds(
panels: Record<string, Panel>,
currentId: string,
targetId: string,
bounds: { top: number; left: number; width: number; height: number },
): { top: number; left: number; width: number; height: number } | null {
if (currentId === targetId) return bounds
const panel = panels[currentId]
if (!panel?.children || panel.children.length !== 2) return null
const [leftId, rightId] = panel.children
const sizes = panel.sizes ?? [50, 50]
const horizontal = panel.direction === "horizontal"
if (horizontal) {
const topHeight = (bounds.height * sizes[0]) / 100
const bottomHeight = bounds.height - topHeight
const topBounds = { ...bounds, height: topHeight }
const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bottomHeight }
return (
computePanelBounds(panels, leftId, targetId, topBounds) ??
computePanelBounds(panels, rightId, targetId, bottomBounds)
)
}
const leftWidth = (bounds.width * sizes[0]) / 100
const rightWidth = bounds.width - leftWidth
const leftBounds = { ...bounds, width: leftWidth }
const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth }
return (
computePanelBounds(panels, leftId, targetId, leftBounds) ??
computePanelBounds(panels, rightId, targetId, rightBounds)
)
}
+3 -18
View File
@@ -7,11 +7,9 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
focused?: boolean
onSubmit?: () => void
onCleanup?: (pty: LocalPTY) => void
onConnectError?: (error: unknown) => void
onExit?: () => void
}
type TerminalColors = {
@@ -40,7 +38,7 @@ export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"])
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
@@ -51,7 +49,6 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let reconnect: number | undefined
let disposed = false
let cleaning = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -91,11 +88,6 @@ export const Terminal = (props: TerminalProps) => {
t.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
createEffect(() => {
if (local.focused) focusTerminal()
})
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
@@ -174,11 +166,6 @@ export const Terminal = (props: TerminalProps) => {
return true
}
// allow cmd+d and cmd+shift+d for terminal splitting
if (event.metaKey && key === "d") {
return true
}
return false
})
@@ -244,6 +231,7 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp)
// })
socket.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -262,9 +250,7 @@ export const Terminal = (props: TerminalProps) => {
props.onConnectError?.(error)
})
socket.addEventListener("close", () => {
if (!cleaning) {
props.onExit?.()
}
console.log("WebSocket disconnected")
})
})
@@ -288,7 +274,6 @@ export const Terminal = (props: TerminalProps) => {
})
}
cleaning = true
ws?.close()
t?.dispose()
})
+12 -2
View File
@@ -1,5 +1,6 @@
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -104,7 +105,15 @@ export function formatKeybind(config: string): string {
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
const arrows: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
}
const displayKey =
arrows[kb.key.toLowerCase()] ??
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
parts.push(displayKey)
}
@@ -114,6 +123,7 @@ export function formatKeybind(config: string): string {
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const dialog = useDialog()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
@@ -157,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
if (suspended() || dialog.active) return
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
+47 -23
View File
@@ -110,6 +110,7 @@ function createGlobalSync() {
})
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
@@ -122,29 +123,33 @@ function createGlobalSync() {
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
limit: 5,
message: {},
part: {},
})
bootstrapInstance(directory)
const init = () => {
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const,
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
limit: 5,
message: {},
part: {},
})
bootstrapInstance(directory)
}
runWithOwner(owner, init)
}
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
@@ -346,6 +351,23 @@ function createGlobalSync() {
bootstrapInstance(directory)
break
}
case "session.created": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
if (!event.properties.info.parentID) {
setStore("sessionTotal", store.sessionTotal + 1)
}
break
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (event.properties.info.time.archived) {
@@ -357,6 +379,8 @@ function createGlobalSync() {
}),
)
}
if (event.properties.info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
if (result.found) {
+11
View File
@@ -36,6 +36,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
@@ -197,6 +198,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
last() {
const key = origin()
if (!key) return
return store.lastProject[key]
},
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
},
},
}
},
+56 -334
View File
@@ -9,31 +9,12 @@ export type LocalPTY = {
id: string
title: string
titleNumber: number
tabId: string
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
export type SplitDirection = "horizontal" | "vertical"
export type Panel = {
id: string
parentId?: string
ptyId?: string
direction?: SplitDirection
children?: [string, string]
sizes?: [number, number]
}
export type TabPane = {
id: string
root: string
panels: Record<string, Panel>
focused?: string
}
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
@@ -44,10 +25,6 @@ type TerminalCacheEntry = {
dispose: VoidFunction
}
function generateId() {
return Math.random().toString(36).slice(2, 10)
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
@@ -56,102 +33,47 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
createStore<{
active?: string
all: LocalPTY[]
panes: Record<string, TabPane>
}>({
all: [],
panes: {},
}),
)
const getNextTitleNumber = () => {
const existing = new Set(store.all.filter((p) => p.tabId === p.id).map((pty) => pty.titleNumber))
let next = 1
while (existing.has(next)) next++
return next
}
const createPty = async (tabId?: string): Promise<LocalPTY | undefined> => {
const tab = tabId ? store.all.find((p) => p.id === tabId) : undefined
const num = tab?.titleNumber ?? getNextTitleNumber()
const title = tab?.title ?? `Terminal ${num}`
const pty = await sdk.client.pty.create({ title }).catch((e) => {
console.error("Failed to create terminal", e)
return undefined
})
if (!pty?.data?.id) return undefined
return {
id: pty.data.id,
title,
titleNumber: num,
tabId: tabId ?? pty.data.id,
}
}
const getAllPtyIds = (pane: TabPane, panelId: string): string[] => {
const panel = pane.panels[panelId]
if (!panel) return []
if (panel.ptyId) return [panel.ptyId]
if (panel.children && panel.children.length === 2) {
return [...getAllPtyIds(pane, panel.children[0]), ...getAllPtyIds(pane, panel.children[1])]
}
return []
}
const getFirstLeaf = (pane: TabPane, panelId: string): string | undefined => {
const panel = pane.panels[panelId]
if (!panel) return undefined
if (panel.ptyId) return panelId
if (panel.children?.[0]) return getFirstLeaf(pane, panel.children[0])
return undefined
}
const migrate = (terminals: LocalPTY[]) =>
terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id }))
const tabCache = new Map<string, LocalPTY>()
const tabs = createMemo(() => {
const migrated = migrate(store.all)
const seen = new Set<string>()
const result: LocalPTY[] = []
for (const p of migrated) {
if (!seen.has(p.tabId)) {
seen.add(p.tabId)
const cached = tabCache.get(p.tabId)
if (cached) {
cached.title = p.title
cached.titleNumber = p.titleNumber
result.push(cached)
} else {
const tab = { ...p, id: p.tabId }
tabCache.set(p.tabId, tab)
result.push(tab)
}
}
}
for (const key of tabCache.keys()) {
if (!seen.has(key)) tabCache.delete(key)
}
return result
})
const all = createMemo(() => migrate(store.all))
return {
ready,
tabs,
all,
active: () => store.active,
panes: () => store.panes,
pane: (tabId: string) => store.panes[tabId],
panel: (tabId: string, panelId: string) => store.panes[tabId]?.panels[panelId],
focused: (tabId: string) => store.panes[tabId]?.focused,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const existingTitleNumbers = new Set(
store.all.map((pty) => {
const match = pty.titleNumber
return match
}),
)
async new() {
const pty = await createPty()
if (!pty) return
setStore("all", [...store.all, pty])
setStore("active", pty.tabId)
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
@@ -164,82 +86,46 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty.create({ title: pty.title }).catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, { ...pty, ...clone.data })
if (store.active === pty.tabId) {
setStore("active", pty.tabId)
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
const pty = store.all.find((x) => x.id === id)
if (!pty) return
const pane = store.panes[pty.tabId]
if (pane) {
const panelId = Object.keys(pane.panels).find((key) => pane.panels[key].ptyId === id)
if (panelId) {
await this.closeSplit(pty.tabId, panelId)
return
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
}
if (store.active === pty.tabId) {
const remaining = store.all.filter((p) => p.tabId === p.id && p.id !== id)
setStore("active", remaining[0]?.tabId)
}
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
async closeTab(tabId: string) {
const pane = store.panes[tabId]
const terminalsInTab = store.all.filter((p) => p.tabId === tabId)
const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id)
const remainingTabs = store.all.filter((p) => p.tabId !== tabId)
const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))]
setStore(
"all",
store.all.filter((x) => !ptyIds.includes(x.id)),
)
setStore(
"panes",
produce((panes) => {
delete panes[tabId]
}),
)
if (store.active === tabId) {
setStore("active", uniqueTabIds[0])
}
for (const ptyId of ptyIds) {
await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
console.error("Failed to close terminal", e)
})
}
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
@@ -250,159 +136,6 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
}),
)
},
async split(tabId: string, direction: SplitDirection) {
const pane = store.panes[tabId]
const newPty = await createPty(tabId)
if (!newPty) return
setStore("all", [...store.all, newPty])
if (!pane) {
const rootId = generateId()
const leftId = generateId()
const rightId = generateId()
setStore("panes", tabId, {
id: tabId,
root: rootId,
panels: {
[rootId]: {
id: rootId,
direction,
children: [leftId, rightId],
sizes: [50, 50],
},
[leftId]: {
id: leftId,
parentId: rootId,
ptyId: tabId,
},
[rightId]: {
id: rightId,
parentId: rootId,
ptyId: newPty.id,
},
},
focused: rightId,
})
} else {
const focusedPanelId = pane.focused
if (!focusedPanelId) return
const focusedPanel = pane.panels[focusedPanelId]
if (!focusedPanel?.ptyId) return
const oldPtyId = focusedPanel.ptyId
const newSplitId = generateId()
const newTerminalId = generateId()
setStore("panes", tabId, "panels", newSplitId, {
id: newSplitId,
parentId: focusedPanelId,
ptyId: oldPtyId,
})
setStore("panes", tabId, "panels", newTerminalId, {
id: newTerminalId,
parentId: focusedPanelId,
ptyId: newPty.id,
})
setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined)
setStore("panes", tabId, "panels", focusedPanelId, "direction", direction)
setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId])
setStore("panes", tabId, "panels", focusedPanelId, "sizes", [50, 50])
setStore("panes", tabId, "focused", newTerminalId)
}
},
focus(tabId: string, panelId: string) {
if (store.panes[tabId]) {
setStore("panes", tabId, "focused", panelId)
}
},
async closeSplit(tabId: string, panelId: string) {
const pane = store.panes[tabId]
if (!pane) return
const panel = pane.panels[panelId]
if (!panel) return
const ptyId = panel.ptyId
if (!ptyId) return
if (!panel.parentId) {
await this.closeTab(tabId)
return
}
const parentPanel = pane.panels[panel.parentId]
if (!parentPanel?.children || parentPanel.children.length !== 2) return
const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0]
const sibling = pane.panels[siblingId]
if (!sibling) return
const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!)
batch(() => {
setStore(
"panes",
tabId,
"panels",
produce((panels) => {
const parent = panels[panel.parentId!]
if (!parent) return
if (sibling.ptyId) {
parent.ptyId = sibling.ptyId
parent.direction = undefined
parent.children = undefined
parent.sizes = undefined
} else if (sibling.children && sibling.children.length === 2) {
parent.ptyId = undefined
parent.direction = sibling.direction
parent.children = sibling.children
parent.sizes = sibling.sizes
panels[sibling.children[0]].parentId = panel.parentId!
panels[sibling.children[1]].parentId = panel.parentId!
}
delete panels[panelId]
delete panels[siblingId]
}),
)
setStore("panes", tabId, "focused", newFocused)
setStore(
"all",
store.all.filter((x) => x.id !== ptyId),
)
})
const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {})
const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId
if (shouldCleanupPane) {
setStore(
"panes",
produce((panes) => {
delete panes[tabId]
}),
)
}
await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
resizeSplit(tabId: string, panelId: string, sizes: [number, number]) {
if (store.panes[tabId]?.panels[panelId]) {
setStore("panes", tabId, "panels", panelId, "sizes", sizes)
}
},
}
}
@@ -456,25 +189,14 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return {
ready: () => session().ready(),
tabs: () => session().tabs(),
all: () => session().all(),
active: () => session().active(),
panes: () => session().panes(),
pane: (tabId: string) => session().pane(tabId),
panel: (tabId: string, panelId: string) => session().panel(tabId, panelId),
focused: (tabId: string) => session().focused(tabId),
new: () => session().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
clone: (id: string) => session().clone(id),
open: (id: string) => session().open(id),
close: (id: string) => session().close(id),
closeTab: (tabId: string) => session().closeTab(tabId),
move: (id: string, to: number) => session().move(id, to),
split: (tabId: string, direction: SplitDirection) => session().split(tabId, direction),
focus: (tabId: string, panelId: string) => session().focus(tabId, panelId),
closeSplit: (tabId: string, panelId: string) => session().closeSplit(tabId, panelId),
resizeSplit: (tabId: string, panelId: string, sizes: [number, number]) =>
session().resizeSplit(tabId, panelId, sizes),
}
},
})
-13
View File
@@ -9,16 +9,3 @@
*[data-tauri-drag-region] {
app-region: drag;
}
/* Terminal split resize handles */
[data-terminal-split-container] [data-component="resize-handle"] {
inset: unset;
&[data-direction="horizontal"] {
height: 100%;
}
&[data-direction="vertical"] {
width: 100%;
}
}
+2 -1
View File
@@ -1,4 +1,3 @@
import { useGlobalSync } from "@/context/global-sync"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
@@ -12,6 +11,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
export default function Home() {
const sync = useGlobalSync()
@@ -24,6 +24,7 @@ export default function Home() {
function openProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}`)
}
+385 -156
View File
@@ -5,12 +5,14 @@ import {
createSignal,
For,
Match,
on,
onCleanup,
onMount,
ParentProps,
Show,
Switch,
untrack,
type Accessor,
type JSX,
} from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
@@ -23,6 +25,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
@@ -70,6 +73,7 @@ export default function Layout(props: ParentProps) {
activeProject: undefined as string | undefined,
activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>,
workspaceName: {} as Record<string, string>,
workspaceExpanded: {} as Record<string, boolean>,
}),
)
@@ -84,6 +88,7 @@ export default function Layout(props: ParentProps) {
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
const params = useParams()
const [autoselect, setAutoselect] = createSignal(!params.dir)
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const layout = useLayout()
@@ -97,6 +102,7 @@ export default function Layout(props: ParentProps) {
const dialog = useDialog()
const command = useCommand()
const theme = useTheme()
const initialDir = params.dir
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeLabel: Record<ColorScheme, string> = {
@@ -105,6 +111,104 @@ export default function Layout(props: ParentProps) {
dark: "Dark",
}
const [editor, setEditor] = createStore({
active: "" as string,
value: "",
})
const editorRef = { current: undefined as HTMLInputElement | undefined }
const editorOpen = (id: string) => editor.active === id
const editorValue = () => editor.value
const openEditor = (id: string, value: string) => {
if (!id) return
setEditor({ active: id, value })
queueMicrotask(() => editorRef.current?.focus())
}
const closeEditor = () => setEditor({ active: "", value: "" })
const saveEditor = (callback: (next: string) => void) => {
const next = editor.value.trim()
if (!next) {
closeEditor()
return
}
closeEditor()
callback(next)
}
const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
if (event.key === "Enter") {
event.preventDefault()
saveEditor(callback)
return
}
if (event.key === "Escape") {
event.preventDefault()
closeEditor()
}
}
const InlineEditor = (props: {
id: string
value: Accessor<string>
onSave: (next: string) => void
class?: string
displayClass?: string
editing?: boolean
stopPropagation?: boolean
openOnDblClick?: boolean
}) => {
const isEditing = () => props.editing ?? editorOpen(props.id)
const stopEvents = () => props.stopPropagation ?? false
const allowDblClick = () => props.openOnDblClick ?? true
const stopPropagation = (event: Event) => {
if (!stopEvents()) return
event.stopPropagation()
}
const handleDblClick = (event: MouseEvent) => {
if (!allowDblClick()) return
stopPropagation(event)
openEditor(props.id, props.value())
}
return (
<Show
when={isEditing()}
fallback={
<span
class={props.displayClass ?? props.class}
onDblClick={handleDblClick}
onPointerDown={stopPropagation}
onMouseDown={stopPropagation}
onClick={stopPropagation}
onTouchStart={stopPropagation}
>
{props.value()}
</span>
}
>
<InlineInput
ref={(el) => {
editorRef.current = el
}}
value={editorValue()}
class={props.class}
onInput={(event) => setEditor("value", event.currentTarget.value)}
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
onBlur={() => closeEditor()}
onPointerDown={stopPropagation}
onClick={stopPropagation}
onDblClick={stopPropagation}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onTouchStart={stopPropagation}
/>
</Show>
)
}
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
if (ids.length === 0) return
@@ -275,12 +379,21 @@ export default function Layout(props: ParentProps) {
return bUpdated - aUpdated
}
function scrollToSession(sessionId: string) {
const [scrollSessionKey, setScrollSessionKey] = createSignal<string | undefined>(undefined)
function scrollToSession(sessionId: string, sessionKey: string) {
if (!scrollContainerRef) return
if (scrollSessionKey() === sessionKey) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
if (element) {
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
if (!element) return
const containerRect = scrollContainerRef.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
setScrollSessionKey(sessionKey)
return
}
setScrollSessionKey(sessionKey)
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
const currentProject = createMemo(() => {
@@ -289,6 +402,49 @@ export default function Layout(props: ParentProps) {
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
createEffect(
on(
() => ({ ready: pageReady(), project: currentProject() }),
(value) => {
if (!value.ready) return
const project = value.project
if (!project) return
const last = server.projects.last()
if (last === project.worktree) return
server.projects.touch(project.worktree)
},
{ defer: true },
),
)
createEffect(
on(
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
(value) => {
if (!value.ready) return
if (!value.layoutReady) return
if (!autoselect()) return
if (initialDir) return
if (value.dir) return
if (value.list.length === 0) return
const last = server.projects.last()
const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
if (!next) return
setAutoselect(false)
openProject(next.worktree, false)
navigateToProject(next.worktree)
},
{ defer: true },
),
)
const workspaceName = (directory: string) => store.workspaceName[directory]
const workspaceLabel = (directory: string, branch?: string) =>
workspaceName(directory) ?? branch ?? getFilename(directory)
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
const workspaceSetting = createMemo(() => {
const project = currentProject()
if (!project) return false
@@ -325,9 +481,12 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!pageReady()) return
if (!layoutReady()) return
const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (layout.sidebar.workspaces(directory)()) continue
if (!expanded) continue
const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (!project) continue
if (layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false)
}
})
@@ -342,7 +501,7 @@ export default function Layout(props: ParentProps) {
const [dirStore] = globalSync.child(dir)
const dirSessions = dirStore.session
.filter((session) => session.directory === dirStore.path.directory)
.filter((session) => !session.parentID)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions)
result.push(...dirSessions)
}
@@ -351,7 +510,7 @@ export default function Layout(props: ParentProps) {
const [projectStore] = globalSync.child(project.worktree)
return projectStore.session
.filter((session) => session.directory === projectStore.path.directory)
.filter((session) => !session.parentID)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions)
})
@@ -533,7 +692,7 @@ export default function Layout(props: ParentProps) {
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id))
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
}
async function archiveSession(session: Session) {
@@ -671,6 +830,7 @@ export default function Layout(props: ParentProps) {
function navigateToProject(directory: string | undefined) {
if (!directory) return
server.projects.touch(directory)
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
layout.mobileSidebar.hide()
@@ -687,6 +847,31 @@ export default function Layout(props: ParentProps) {
if (navigate) navigateToProject(directory)
}
const displayName = (project: LocalProject) => project.name || getFilename(project.worktree)
async function renameProject(project: LocalProject, next: string) {
if (!project.id) return
const current = displayName(project)
if (next === current) return
const name = next === getFilename(project.worktree) ? "" : next
await globalSDK.client.project.update({ projectID: project.id, name })
}
async function renameSession(session: Session, next: string) {
if (next === session.title) return
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
title: next,
})
}
const renameWorkspace = (directory: string, next: string) => {
const current = workspaceName(directory) ?? getFilename(directory)
if (current === next) return
setStore("workspaceName", directory, next)
}
function closeProject(directory: string) {
const index = layout.projects.list().findIndex((x) => x.worktree === directory)
const next = layout.projects.list()[index + 1]
@@ -721,16 +906,26 @@ export default function Layout(props: ParentProps) {
}
}
createEffect(() => {
if (!pageReady()) return
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
const id = params.id
setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => setStore("workspaceExpanded", directory, (value) => value ?? true))
requestAnimationFrame(() => scrollToSession(id))
})
createEffect(
on(
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
(value) => {
if (!value.ready) return
const dir = value.dir
const id = value.id
if (!dir || !id) return
const directory = base64Decode(dir)
setStore("lastSession", directory, id)
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {
setStore("workspaceExpanded", directory, true)
}
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
},
{ defer: true },
),
)
createEffect(() => {
const project = currentProject()
@@ -747,15 +942,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.loadSessions(project.worktree)
})
createEffect(() => {
if (isLargeViewport()) {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
return
}
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
})
function getDraggableId(event: unknown): string | undefined {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
@@ -930,9 +1116,14 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -970,116 +1161,6 @@ export default function Layout(props: ParentProps) {
)
}
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
const name = data.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name}`
}
const sessions = (directory: string) => {
const [data] = globalSync.child(directory)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
.slice(0, 2)
}
const projectSessions = () => {
const [data] = globalSync.child(props.project.worktree)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
.slice(0, 2)
}
const trigger = (
<button
type="button"
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectIcon project={props.project} notify />
</button>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
fallback={
<For each={projectSessions()}>
{(session) => (
<SessionItem
session={session}
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
/>
)}
</For>
}
>
<For each={workspaces()}>
{(directory) => (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
</div>
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
)}
</For>
</div>
)}
</For>
</Show>
</div>
<Show when={!selected()}>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</Show>
</div>
</HoverCard>
</div>
)
}
const ProjectDragOverlay = (): JSX.Element => {
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
return (
@@ -1102,7 +1183,7 @@ export default function Layout(props: ParentProps) {
const [workspaceStore] = globalSync.child(directory)
const kind = directory === project.worktree ? "local" : "sandbox"
const name = workspaceStore.vcs?.branch ?? getFilename(directory)
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
return `${kind} : ${name}`
})
@@ -1122,14 +1203,13 @@ export default function Layout(props: ParentProps) {
const sessions = createMemo(() =>
workspaceStore.session
.filter((session) => session.directory === workspaceStore.path.directory)
.filter((session) => !session.parentID)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions),
)
const local = createMemo(() => props.directory === props.project.worktree)
const title = createMemo(() => {
const kind = local() ? "local" : "sandbox"
const workspaceValue = createMemo(() => {
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
return `${kind} : ${name}`
return workspaceName(props.directory) ?? name
})
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
@@ -1140,23 +1220,50 @@ export default function Layout(props: ParentProps) {
await globalSync.project.loadSessions(props.directory)
}
const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`))
const openWrapper = (value: boolean) => {
setStore("workspaceExpanded", props.directory, value)
if (value) return
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
}
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible
variant="ghost"
open={open()}
class="shrink-0"
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
<div class="group/trigger relative">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-all group-hover/trigger:pr-16 group-focus-within/trigger:pr-16">
<div class="flex items-center gap-1 min-w-0">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="truncate text-14-medium text-text-base">{title()}</span>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
@@ -1222,13 +1329,123 @@ export default function Layout(props: ParentProps) {
)
}
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
const name = workspaceLabel(directory, data.vcs?.branch)
return `${kind} : ${name}`
}
const sessions = (directory: string) => {
const [data] = globalSync.child(directory)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions)
.slice(0, 2)
}
const projectSessions = () => {
const [data] = globalSync.child(props.project.worktree)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions)
.slice(0, 2)
}
const trigger = (
<button
type="button"
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectIcon project={props.project} notify />
</button>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={6} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
fallback={
<For each={projectSessions()}>
{(session) => (
<SessionItem
session={session}
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
/>
)}
</For>
}
>
<For each={workspaces()}>
{(directory) => (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
</div>
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
)}
</For>
</div>
)}
</For>
</Show>
</div>
<Show when={!selected()}>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</Show>
</div>
</HoverCard>
</div>
)
}
const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() =>
workspaceStore.session
.filter((session) => session.directory === workspaceStore.path.directory)
.filter((session) => !session.parentID)
.filter((session) => !session.parentID && !session.time?.archived)
.toSorted(sortSessions),
)
const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0)
@@ -1244,6 +1461,7 @@ export default function Layout(props: ParentProps) {
if (!props.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }}
>
<nav class="flex flex-col gap-1 px-2">
<Show when={loading()}>
@@ -1282,6 +1500,7 @@ export default function Layout(props: ParentProps) {
if (!current) return ""
return current.name || getFilename(current.worktree)
})
const projectId = createMemo(() => project()?.id ?? "")
const workspaces = createMemo(() => workspaceIds(project()))
const errorMessage = (err: unknown) => {
@@ -1382,13 +1601,22 @@ export default function Layout(props: ParentProps) {
<div class="shrink-0 px-2 py-1">
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
<div class="flex flex-col min-w-0">
<span class="text-16-medium text-text-strong truncate">{projectName()}</span>
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => project() && renameProject(project()!, next)}
class="text-16-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -1445,7 +1673,7 @@ export default function Layout(props: ParentProps) {
New workspace
</Button>
</div>
<div class="flex-1 min-h-0">
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
@@ -1459,6 +1687,7 @@ export default function Layout(props: ParentProps) {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }}
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
+12 -39
View File
@@ -26,7 +26,6 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { TerminalSplit } from "@/components/terminal-split"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
@@ -171,7 +170,6 @@ export default function Page() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
const activeTerminal = createMemo(() => terminal.active())
if (import.meta.env.DEV) {
createEffect(
@@ -382,7 +380,7 @@ export default function Page() {
createEffect(() => {
if (!view().terminal.opened()) return
if (!terminal.ready()) return
if (terminal.tabs().length !== 0) return
if (terminal.all().length !== 0) return
terminal.new()
})
@@ -421,7 +419,6 @@ export default function Page() {
{
id: "session.new",
title: "New session",
description: "Create a new session",
category: "Session",
keybind: "mod+shift+s",
slash: "new",
@@ -439,7 +436,7 @@ export default function Page() {
{
id: "terminal.toggle",
title: "Toggle terminal",
description: "Show or hide the terminal",
description: "",
category: "View",
keybind: "ctrl+`",
slash: "terminal",
@@ -448,7 +445,7 @@ export default function Page() {
{
id: "review.toggle",
title: "Toggle review",
description: "Show or hide the review panel",
description: "",
category: "View",
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
@@ -461,30 +458,6 @@ export default function Page() {
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
{
id: "terminal.split.vertical",
title: "Split terminal right",
description: "Split the current terminal vertically",
category: "Terminal",
keybind: "mod+d",
disabled: !terminal.active(),
onSelect: () => {
const active = terminal.active()
if (active) terminal.split(active, "vertical")
},
},
{
id: "terminal.split.horizontal",
title: "Split terminal down",
description: "Split the current terminal horizontally",
category: "Terminal",
keybind: "mod+shift+d",
disabled: !terminal.active(),
onSelect: () => {
const active = terminal.active()
if (active) terminal.split(active, "horizontal")
},
},
{
id: "steps.toggle",
title: "Toggle steps",
@@ -733,7 +706,7 @@ export default function Page() {
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = terminal.tabs()
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
@@ -1035,7 +1008,7 @@ export default function Page() {
createEffect(() => {
if (!terminal.ready()) return
handoff.terminals = terminal.tabs().map((t) => t.title)
handoff.terminals = terminal.all().map((t) => t.title)
})
createEffect(() => {
@@ -1692,10 +1665,10 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={activeTerminal()} onChange={terminal.open}>
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.tabs().map((t: LocalPTY) => t.id)}>
<For each={terminal.tabs()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
@@ -1707,10 +1680,10 @@ export default function Page() {
</TooltipKeybind>
</div>
</Tabs.List>
<For each={terminal.tabs()}>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id} class="h-[calc(100%-2.5rem)]">
<TerminalSplit tabId={pty.id} />
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
@@ -1718,7 +1691,7 @@ export default function Page() {
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const pty = createMemo(() => terminal.tabs().find((t: LocalPTY) => t.id === draggedId()))
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {
@@ -183,7 +183,12 @@ export async function POST(input: APIEvent) {
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
subscription: {
status: "subscribed",
coupon: couponID,
seats: 1,
plan: "200",
},
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
@@ -408,7 +413,7 @@ export async function POST(input: APIEvent) {
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({ subscriptionID: null, subscriptionCouponID: null })
.set({ subscriptionID: null, subscription: null })
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
@@ -65,7 +65,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
buffer = newBuffer
const messages = []
while (buffer.length >= 4) {
// first 4 bytes are the total length (big-endian)
const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false)
@@ -121,7 +120,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
const parsedDataResult = JSON.parse(data)
delete parsedDataResult.p
const bytes = atob(parsedDataResult.bytes)
const binary = atob(parsedDataResult.bytes)
const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0))
const bytes = decoder.decode(uint8)
const eventName = JSON.parse(bytes).type
messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join(""))
}
@@ -0,0 +1 @@
ALTER TABLE `billing` ADD `subscription` json;
@@ -0,0 +1 @@
ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -372,6 +372,20 @@
"when": 1768343920467,
"tag": "0052_aromatic_agent_zero",
"breakpoints": true
},
{
"idx": 53,
"version": "5",
"when": 1768599366758,
"tag": "0053_gigantic_hardball",
"breakpoints": true
},
{
"idx": 54,
"version": "5",
"when": 1768603665356,
"tag": "0054_numerous_annihilus",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.23",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",
+112
View File
@@ -0,0 +1,112 @@
import { Billing } from "../src/billing.js"
import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js"
import { Identifier } from "../src/identifier.js"
import { centsToMicroCents } from "../src/util/price.js"
import { AuthTable } from "../src/schema/auth.sql.js"
const plan = "200"
const workspaceID = process.argv[2]
const seats = parseInt(process.argv[3])
console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`)
if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts <workspaceID> <seats>")
// Get workspace user
const users = await Database.use((tx) =>
tx
.select({
id: UserTable.id,
role: UserTable.role,
email: AuthTable.subject,
})
.from(UserTable)
.leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email")))
.where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
)
if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`)
if (users.length !== seats)
throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`)
const adminUser = users.find((user) => user.role === "admin")
if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`)
if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`)
// Get Billing
const billing = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`)
if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`)
// Look up the Stripe customer by email
const customerID =
billing.customerID ??
(await (() =>
Billing.stripe()
.customers.create({
email: adminUser.email,
metadata: {
workspaceID,
},
})
.then((customer) => customer.id))())
console.log(`Customer ID: ${customerID}`)
const couponID = "JAIr0Pe1"
const subscription = await Billing.stripe().subscriptions.create({
customer: customerID!,
items: [
{
price: `price_1SmfyI2StuRr0lbXovxJNeZn`,
discounts: [{ coupon: couponID }],
quantity: 2,
},
],
})
console.log(`Subscription ID: ${subscription.id}`)
await Database.transaction(async (tx) => {
// Set customer id, subscription id, and payment method on workspace billing
await tx
.update(BillingTable)
.set({
customerID,
subscriptionID: subscription.id,
subscription: { status: "subscribed", coupon: couponID, seats, plan },
})
.where(eq(BillingTable.workspaceID, workspaceID))
// Create a row in subscription table
for (const user of users) {
await tx.insert(SubscriptionTable).values({
workspaceID,
id: Identifier.create("subscription"),
userID: user.id,
})
}
//
// // Create a row in payments table
// await tx.insert(PaymentTable).values({
// workspaceID,
// id: Identifier.create("payment"),
// amount: centsToMicroCents(amountInCents),
// customerID,
// invoiceID,
// paymentID,
// enrichment: {
// type: "subscription",
// couponID,
// },
// })
})
console.log(`done`)
@@ -12,7 +12,7 @@ const email = process.argv[3]
console.log(`Onboarding workspace ${workspaceID} for email ${email}`)
if (!workspaceID || !email) {
console.error("Usage: bun onboard-zen-black.ts <workspaceID> <email>")
console.error("Usage: bun foo.ts <workspaceID> <email>")
process.exit(1)
}
@@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) =>
tx
.select({ workspaceID: BillingTable.workspaceID })
.from(BillingTable)
.where(eq(BillingTable.subscriptionID, subscriptionID))
.where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`)
.then((rows) => rows[0]),
)
if (existingSubscription) {
@@ -128,10 +128,15 @@ await Database.transaction(async (tx) => {
.set({
customerID,
subscriptionID,
subscriptionCouponID: couponID,
paymentMethodID,
paymentMethodLast4,
paymentMethodType,
subscription: {
status: "subscribed",
coupon: couponID,
seats: 1,
plan: "200",
},
})
.where(eq(BillingTable.workspaceID, workspaceID))
@@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) =>
.select({
customerID: BillingTable.customerID,
subscriptionID: BillingTable.subscriptionID,
subscriptionCouponID: BillingTable.subscriptionCouponID,
subscription: BillingTable.subscription,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodType: BillingTable.paymentMethodType,
paymentMethodLast4: BillingTable.paymentMethodLast4,
@@ -119,7 +119,7 @@ await Database.transaction(async (tx) => {
.set({
customerID: fromPrevPayment.customerID,
subscriptionID: null,
subscriptionCouponID: null,
subscription: null,
paymentMethodID: fromPrevPaymentMethods.data[0].id,
paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null,
paymentMethodType: fromPrevPaymentMethods.data[0].type,
@@ -131,7 +131,7 @@ await Database.transaction(async (tx) => {
.set({
customerID: fromBilling.customerID,
subscriptionID: fromBilling.subscriptionID,
subscriptionCouponID: fromBilling.subscriptionCouponID,
subscription: fromBilling.subscription,
paymentMethodID: fromBilling.paymentMethodID,
paymentMethodLast4: fromBilling.paymentMethodLast4,
paymentMethodType: fromBilling.paymentMethodType,
+12 -6
View File
@@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) {
),
)
// Get all payments for these workspaces
await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID)))
for (const user of users) {
await printWorkspace(user.workspaceID)
}
}
async function printWorkspace(workspaceID: string) {
@@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) {
balance: BillingTable.balance,
customerID: BillingTable.customerID,
reload: BillingTable.reload,
subscriptionID: BillingTable.subscriptionID,
subscription: {
id: BillingTable.subscriptionID,
couponID: BillingTable.subscriptionCouponID,
plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription,
},
})
.from(BillingTable)
@@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) {
rows.map((row) => ({
...row,
balance: `$${(row.balance / 100000000).toFixed(2)}`,
subscription: row.subscription.id
? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}`
subscription: row.subscriptionID
? [
`Black ${row.subscription.enrichment!.plan}`,
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "",
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "",
`(ref: ${row.subscriptionID})`,
].join(" ")
: row.subscription.booked
? `Waitlist ${row.subscription.plan} plan`
: undefined,
@@ -21,8 +21,13 @@ export const BillingTable = mysqlTable(
reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"),
subscription: json("subscription").$type<{
status: "subscribed"
coupon?: string
seats: number
plan: "20" | "100" | "200"
}>(),
subscriptionID: varchar("subscription_id", { length: 28 }),
subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }),
subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const),
timeSubscriptionBooked: utc("time_subscription_booked"),
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.23",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.23",
"version": "1.1.25",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {
+12
View File
@@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
)
}
const isWindows = ostype() === "windows"
if (isWindows) {
const originalGetComputedStyle = window.getComputedStyle
window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
if (!(elt instanceof Element)) {
// WebView2 can call into Floating UI with non-elements; fall back to a safe element.
return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined)
}
return originalGetComputedStyle(elt, pseudoElt ?? undefined)
}) as typeof window.getComputedStyle
}
let update: Update | null = null
const createPlatform = (password: Accessor<string | null>): Platform => ({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.23",
"version": "1.1.25",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.23"
version = "1.1.25"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.23/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.23",
"version": "1.1.25",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.23",
"version": "1.1.25",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -82,8 +82,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
+5 -1
View File
@@ -2,6 +2,7 @@ import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
@@ -71,7 +72,10 @@ export namespace BunProc {
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
return result
})
if (parsed.dependencies[pkg] === version) return mod
const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod)
if (dependencies[pkg] === version && modExists) return mod
const proxied = !!(
process.env.HTTP_PROXY ||
-6
View File
@@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthCallback } from "../../mcp/oauth-callback"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config/config"
import { Instance } from "../../project/instance"
@@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
// Start callback server
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
@@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
-10
View File
@@ -200,11 +200,6 @@ function App() {
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
// @ts-expect-error writeOut is not in type definitions
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -627,11 +622,6 @@ function App() {
}
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -145,9 +145,9 @@ export function Prompt(props: PromptProps) {
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
})
@@ -697,11 +697,6 @@ export function Session() {
return
}
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
Clipboard.copy(text)
.then(() => toast.show({ message: "Message copied to clipboard!", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" }))
@@ -161,6 +161,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") {
const option = selected()
if (option) {
@@ -141,11 +141,6 @@ export function DialogProvider(props: ParentProps) {
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
/* @ts-expect-error */
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
@@ -5,6 +5,21 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
// tmux and screen require DCS passthrough wrapping
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
data: string
@@ -123,6 +138,7 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
await getCopyMethod()(text)
}
}
+5 -1
View File
@@ -60,7 +60,11 @@ export const WebCommand = cmd({
}
if (opts.mdns) {
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
UI.println(
UI.Style.TEXT_INFO_BOLD + " mDNS: ",
UI.Style.TEXT_NORMAL,
`opencode.local:${server.port}`,
)
}
// Open localhost in browser
-4
View File
@@ -435,10 +435,6 @@ export namespace Config {
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
+10 -17
View File
@@ -308,8 +308,6 @@ export namespace MCP {
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
authProvider = new McpOAuthProvider(
key,
mcp.url,
@@ -317,7 +315,6 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@@ -347,7 +344,6 @@ export namespace MCP {
let lastError: Error | undefined
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
for (const { name, transport } of transports) {
try {
const client = new Client({
@@ -574,8 +570,7 @@ export namespace MCP {
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
// Only include tools from connected MCPs (skip disabled ones)
const clientStatus = s.status[clientName]?.status
if (clientStatus !== "connected") {
if (s.status[clientName]?.status !== "connected") {
continue
}
@@ -725,10 +720,8 @@ export namespace MCP {
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
}
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
// Start the callback server
await McpOAuthCallback.ensureRunning()
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
// The SDK will call provider.state() to read this value
@@ -738,6 +731,8 @@ export namespace MCP {
await McpAuth.updateOAuthState(mcpName, oauthState)
// Create a new auth provider for this flow
// OAuth config is optional - if not provided, we'll use auto-discovery
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
let capturedUrl: URL | undefined
const authProvider = new McpOAuthProvider(
mcpName,
@@ -746,7 +741,6 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@@ -775,7 +769,6 @@ export namespace MCP {
pendingOAuthTransports.set(mcpName, transport)
return { authorizationUrl: capturedUrl.toString() }
}
throw error
}
}
@@ -785,9 +778,9 @@ export namespace MCP {
* Opens the browser and waits for callback.
*/
export async function authenticate(mcpName: string): Promise<Status> {
const result = await startAuth(mcpName)
const { authorizationUrl } = await startAuth(mcpName)
if (!result.authorizationUrl) {
if (!authorizationUrl) {
// Already authenticated
const s = await state()
return s.status[mcpName] ?? { status: "connected" }
@@ -801,9 +794,9 @@ export namespace MCP {
// The SDK has already added the state parameter to the authorization URL
// We just need to open the browser
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState })
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
try {
const subprocess = await open(result.authorizationUrl)
const subprocess = await open(authorizationUrl)
// The open package spawns a detached process and returns immediately.
// We need to listen for errors which fire asynchronously:
// - "error" event: command not found (ENOENT)
@@ -826,7 +819,7 @@ export namespace MCP {
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
// Emit event so CLI can display the URL for manual opening
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl })
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
}
// Wait for callback using the OAuth state parameter
+9 -25
View File
@@ -1,12 +1,8 @@
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
let currentPort = OAUTH_CALLBACK_PORT
let currentPath = OAUTH_CALLBACK_PATH
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
@@ -60,33 +56,21 @@ export namespace McpOAuthCallback {
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
export async function ensureRunning(redirectUri?: string): Promise<void> {
// Parse the redirect URI to get port and path (uses defaults if not provided)
const { port, path } = parseRedirectUri(redirectUri)
// If server is running on a different port/path, stop it first
if (server && (currentPort !== port || currentPath !== path)) {
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
await stop()
}
export async function ensureRunning(): Promise<void> {
if (server) return
const running = await isPortInUse(port)
const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port })
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}
currentPort = port
currentPath = path
server = Bun.serve({
port: currentPort,
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== currentPath) {
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
@@ -149,7 +133,7 @@ export namespace McpOAuthCallback {
},
})
log.info("oauth callback server started", { port: currentPort, path: currentPath })
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(oauthState: string): Promise<string> {
@@ -174,11 +158,11 @@ export namespace McpOAuthCallback {
}
}
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port,
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
@@ -17,7 +17,6 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
redirectUri?: string
}
export interface McpOAuthCallbacks {
@@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
) {}
get redirectUrl(): string {
// Use configured redirectUri if provided, otherwise use OpenCode defaults
if (this.config.redirectUri) {
return this.config.redirectUri
}
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
}
@@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
/**
* Parse a redirect URI to extract port and path for the callback server.
* Returns defaults if the URI can't be parsed.
*/
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
if (!redirectUri) {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
try {
const url = new URL(redirectUri)
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
const path = url.pathname || OAUTH_CALLBACK_PATH
return { port, path }
} catch {
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
}
}
+22 -2
View File
@@ -3,6 +3,9 @@ import { Installation } from "@/installation"
import { iife } from "@/util/iife"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
// slightly too early due to clock skew / timer drift.
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds
function normalizeDomain(url: string) {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "")
@@ -204,6 +207,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const data = (await response.json()) as {
access_token?: string
error?: string
interval?: number
}
if (data.access_token) {
@@ -230,13 +234,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error === "slow_down") {
// Based on the RFC spec, we must add 5 seconds to our current polling interval.
// (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5)
let newInterval = (deviceData.interval + 5) * 1000
// GitHub OAuth API may return the new interval in seconds in the response.
// We should try to use that if provided with safety margin.
const serverInterval = data.interval
if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000))
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},
@@ -999,6 +999,24 @@ export namespace Provider {
opts.signal = combined
}
// Strip openai itemId metadata following what codex does
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
// IDs are only re-attached for Azure with store=true
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
const body = JSON.parse(opts.body as string)
const isAzure = model.providerID.includes("azure")
const keepIds = isAzure && body.store === true
if (!keepIds && Array.isArray(body.input)) {
for (const item of body.input) {
if ("id" in item) {
delete item.id
}
}
opts.body = JSON.stringify(body)
}
}
return fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
+46 -60
View File
@@ -16,38 +16,33 @@ function mimeToModality(mime: string): Modality | undefined {
}
export namespace ProviderTransform {
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
case "@ai-sdk/github-copilot":
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return "openai"
case "@ai-sdk/amazon-bedrock":
return "bedrock"
case "@ai-sdk/anthropic":
return "anthropic"
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
return "google"
case "@ai-sdk/gateway":
return "gateway"
case "@openrouter/ai-sdk-provider":
return "openrouter"
}
return undefined
}
function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
options: Record<string, unknown>,
): ModelMessage[] {
// Strip openai itemId metadata following what codex does
if (model.api.npm === "@ai-sdk/openai" || options.store === false) {
msgs = msgs.map((msg) => {
if (msg.providerOptions) {
for (const options of Object.values(msg.providerOptions)) {
if (options && typeof options === "object") {
delete options["itemId"]
}
}
}
if (!Array.isArray(msg.content)) {
return msg
}
const content = msg.content.map((part) => {
if (part.providerOptions) {
for (const options of Object.values(part.providerOptions)) {
if (options && typeof options === "object") {
delete options["itemId"]
}
}
}
return part
})
return { ...msg, content } as typeof msg
})
}
// Anthropic rejects messages with empty content - filter out empty string messages
// and remove empty text/reasoning parts from array content
if (model.api.npm === "@ai-sdk/anthropic") {
@@ -261,6 +256,28 @@ export namespace ProviderTransform {
msgs = applyCaching(msgs, model.providerID)
}
// Remap providerOptions keys from stored providerID to expected SDK key
const key = sdkKey(model.api.npm)
if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {
const remap = (opts: Record<string, any> | undefined) => {
if (!opts) return opts
if (!(model.providerID in opts)) return opts
const result = { ...opts }
result[key] = result[model.providerID]
delete result[model.providerID]
return result
}
msgs = msgs.map((msg) => {
if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) }
return {
...msg,
providerOptions: remap(msg.providerOptions),
content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })),
} as typeof msg
})
}
return msgs
}
@@ -578,39 +595,8 @@ export namespace ProviderTransform {
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
switch (model.api.npm) {
case "@ai-sdk/github-copilot":
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return {
["openai" as string]: options,
}
case "@ai-sdk/amazon-bedrock":
return {
["bedrock" as string]: options,
}
case "@ai-sdk/anthropic":
return {
["anthropic" as string]: options,
}
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
return {
["google" as string]: options,
}
case "@ai-sdk/gateway":
return {
["gateway" as string]: options,
}
case "@openrouter/ai-sdk-provider":
return {
["openrouter" as string]: options,
}
default:
return {
[model.providerID]: options,
}
}
const key = sdkKey(model.api.npm) ?? model.providerID
return { [key]: options }
}
export function maxOutputTokens(
-4
View File
@@ -146,10 +146,6 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
for (const ws of session.subscribers) {
ws.close()
}
session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
state().delete(id)
})
+3 -1
View File
@@ -7,15 +7,17 @@ export namespace MDNS {
let bonjour: Bonjour | undefined
let currentPort: number | undefined
export function publish(port: number, name = "opencode") {
export function publish(port: number) {
if (currentPort === port) return
if (bonjour) unpublish()
try {
const name = `opencode-${port}`
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
host: "opencode.local",
port,
txt: { path: "/" },
})
-79
View File
@@ -1,79 +0,0 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import z from "zod"
import { errors } from "./error"
export const ProjectRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
operationId: "project.list",
responses: {
200: {
description: "List of projects",
content: {
"application/json": {
schema: resolver(Project.Info.array()),
},
},
},
},
}),
async (c) => {
const projects = await Project.list()
return c.json(projects)
},
)
.get(
"/current",
describeRoute({
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
operationId: "project.current",
responses: {
200: {
description: "Current project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
return c.json(Instance.project)
},
)
.patch(
"/:projectID",
describeRoute({
summary: "Update project",
description: "Update project properties such as name, icon and color.",
operationId: "project.update",
responses: {
200: {
description: "Updated project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
return c.json(project)
},
)
-95
View File
@@ -1,95 +0,0 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../question"
import z from "zod"
import { errors } from "./error"
export const QuestionRoute = new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
)
@@ -0,0 +1,92 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Provider } from "../../provider/provider"
import { mapValues } from "remeda"
import { errors } from "../error"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const ConfigRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "Get configuration",
description: "Retrieve the current OpenCode configuration settings and preferences.",
operationId: "config.get",
responses: {
200: {
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
},
}),
async (c) => {
return c.json(await Config.get())
},
)
.patch(
"/",
describeRoute({
summary: "Update configuration",
description: "Update OpenCode configuration settings and preferences.",
operationId: "config.update",
responses: {
200: {
description: "Successfully updated config",
content: {
"application/json": {
schema: resolver(Config.Info),
},
},
},
...errors(400),
},
}),
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
return c.json(config)
},
)
.get(
"/providers",
describeRoute({
summary: "List config providers",
description: "Get a list of all configured AI providers and their default models.",
operationId: "config.providers",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
providers: Provider.Info.array(),
default: z.record(z.string(), z.string()),
}),
),
},
},
},
},
}),
async (c) => {
using _ = log.time("providers")
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
return c.json({
providers: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
})
},
),
)
@@ -0,0 +1,157 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { ToolRegistry } from "../../tool/registry"
import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ExperimentalRoutes = lazy(() =>
new Hono()
.get(
"/tool/ids",
describeRoute({
summary: "List tool IDs",
description:
"Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.",
operationId: "tool.ids",
responses: {
200: {
description: "Tool IDs",
content: {
"application/json": {
schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })),
},
},
},
...errors(400),
},
}),
async (c) => {
return c.json(await ToolRegistry.ids())
},
)
.get(
"/tool",
describeRoute({
summary: "List tools",
description:
"Get a list of available tools with their JSON schema parameters for a specific provider and model combination.",
operationId: "tool.list",
responses: {
200: {
description: "Tools",
content: {
"application/json": {
schema: resolver(
z
.array(
z
.object({
id: z.string(),
description: z.string(),
parameters: z.any(),
})
.meta({ ref: "ToolListItem" }),
)
.meta({ ref: "ToolList" }),
),
},
},
},
...errors(400),
},
}),
validator(
"query",
z.object({
provider: z.string(),
model: z.string(),
}),
),
async (c) => {
const { provider } = c.req.valid("query")
const tools = await ToolRegistry.tools(provider)
return c.json(
tools.map((t) => ({
id: t.id,
description: t.description,
// Handle both Zod schemas and plain JSON schemas
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
})),
)
},
)
.post(
"/worktree",
describeRoute({
summary: "Create worktree",
description: "Create a new git worktree for the current project.",
operationId: "worktree.create",
responses: {
200: {
description: "Worktree created",
content: {
"application/json": {
schema: resolver(Worktree.Info),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.create.schema),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
return c.json(worktree)
},
)
.get(
"/worktree",
describeRoute({
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
operationId: "worktree.list",
responses: {
200: {
description: "List of worktree directories",
content: {
"application/json": {
schema: resolver(z.array(z.string())),
},
},
},
},
}),
async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id)
return c.json(sandboxes)
},
)
.get(
"/resource",
describeRoute({
summary: "Get MCP resources",
description: "Get all available MCP resources from connected servers. Optionally filter by name.",
operationId: "experimental.resource.list",
responses: {
200: {
description: "MCP resources",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Resource)),
},
},
},
},
}),
async (c) => {
return c.json(await MCP.resources())
},
),
)
+197
View File
@@ -0,0 +1,197 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { File } from "../../file"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { Instance } from "../../project/instance"
import { lazy } from "../../util/lazy"
export const FileRoutes = lazy(() =>
new Hono()
.get(
"/find",
describeRoute({
summary: "Find text",
description: "Search for text patterns across files in the project using ripgrep.",
operationId: "find.text",
responses: {
200: {
description: "Matches",
content: {
"application/json": {
schema: resolver(Ripgrep.Match.shape.data.array()),
},
},
},
},
}),
validator(
"query",
z.object({
pattern: z.string(),
}),
),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await Ripgrep.search({
cwd: Instance.directory,
pattern,
limit: 10,
})
return c.json(result)
},
)
.get(
"/find/file",
describeRoute({
summary: "Find files",
description: "Search for files or directories by name or pattern in the project directory.",
operationId: "find.files",
responses: {
200: {
description: "File paths",
content: {
"application/json": {
schema: resolver(z.string().array()),
},
},
},
},
}),
validator(
"query",
z.object({
query: z.string(),
dirs: z.enum(["true", "false"]).optional(),
type: z.enum(["file", "directory"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
})
return c.json(results)
},
)
.get(
"/find/symbol",
describeRoute({
summary: "Find symbols",
description: "Search for workspace symbols like functions, classes, and variables using LSP.",
operationId: "find.symbols",
responses: {
200: {
description: "Symbols",
content: {
"application/json": {
schema: resolver(LSP.Symbol.array()),
},
},
},
},
}),
validator(
"query",
z.object({
query: z.string(),
}),
),
async (c) => {
/*
const query = c.req.valid("query").query
const result = await LSP.workspaceSymbol(query)
return c.json(result)
*/
return c.json([])
},
)
.get(
"/file",
describeRoute({
summary: "List files",
description: "List files and directories in a specified path.",
operationId: "file.list",
responses: {
200: {
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
},
},
},
},
}),
validator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
return c.json(content)
},
)
.get(
"/file/content",
describeRoute({
summary: "Read file",
description: "Read the content of a specified file.",
operationId: "file.read",
responses: {
200: {
description: "File content",
content: {
"application/json": {
schema: resolver(File.Content),
},
},
},
},
}),
validator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
return c.json(content)
},
)
.get(
"/file/status",
describeRoute({
summary: "Get file status",
description: "Get the git status of all files in the project.",
operationId: "file.status",
responses: {
200: {
description: "File status",
content: {
"application/json": {
schema: resolver(File.Info.array()),
},
},
},
},
}),
async (c) => {
const content = await File.status()
return c.json(content)
},
),
)
@@ -0,0 +1,135 @@
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
export const GlobalRoutes = lazy(() =>
new Hono()
.get(
"/health",
describeRoute({
summary: "Get health",
description: "Get health information about the OpenCode server.",
operationId: "global.health",
responses: {
200: {
description: "Health information",
content: {
"application/json": {
schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })),
},
},
},
},
}),
async (c) => {
return c.json({ healthy: true, version: Installation.VERSION })
},
)
.get(
"/event",
describeRoute({
summary: "Get global events",
description: "Subscribe to global events from the OpenCode system using server-sent events.",
operationId: "global.event",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(
z
.object({
directory: z.string(),
payload: BusEvent.payloads(),
})
.meta({
ref: "GlobalEvent",
}),
),
},
},
},
},
}),
async (c) => {
log.info("global event connected")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
})
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
GlobalBus.on("event", handler)
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
})
}, 30000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
log.info("global event disconnected")
})
})
})
},
)
.post(
"/dispose",
describeRoute({
summary: "Dispose instance",
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
operationId: "global.dispose",
responses: {
200: {
description: "Global disposed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
type: GlobalDisposedEvent.type,
properties: {},
},
})
return c.json(true)
},
),
)
+225
View File
@@ -0,0 +1,225 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "../../mcp"
import { Config } from "../../config/config"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const McpRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "Get MCP status",
description: "Get the status of all Model Context Protocol (MCP) servers.",
operationId: "mcp.status",
responses: {
200: {
description: "MCP server status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
},
}),
async (c) => {
return c.json(await MCP.status())
},
)
.post(
"/",
describeRoute({
summary: "Add MCP server",
description: "Dynamically add a new Model Context Protocol (MCP) server to the system.",
operationId: "mcp.add",
responses: {
200: {
description: "MCP server added successfully",
content: {
"application/json": {
schema: resolver(z.record(z.string(), MCP.Status)),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
name: z.string(),
config: Config.Mcp,
}),
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await MCP.add(name, config)
return c.json(result.status)
},
)
.post(
"/:name/auth",
describeRoute({
summary: "Start MCP OAuth",
description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.",
operationId: "mcp.auth.start",
responses: {
200: {
description: "OAuth flow started",
content: {
"application/json": {
schema: resolver(
z.object({
authorizationUrl: z.string().describe("URL to open in browser for authorization"),
}),
),
},
},
},
...errors(400, 404),
},
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const result = await MCP.startAuth(name)
return c.json(result)
},
)
.post(
"/:name/auth/callback",
describeRoute({
summary: "Complete MCP OAuth",
description:
"Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.",
operationId: "mcp.auth.callback",
responses: {
200: {
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
},
},
},
...errors(400, 404),
},
}),
validator(
"json",
z.object({
code: z.string().describe("Authorization code from OAuth callback"),
}),
),
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await MCP.finishAuth(name, code)
return c.json(status)
},
)
.post(
"/:name/auth/authenticate",
describeRoute({
summary: "Authenticate MCP OAuth",
description: "Start OAuth flow and wait for callback (opens browser)",
operationId: "mcp.auth.authenticate",
responses: {
200: {
description: "OAuth authentication completed",
content: {
"application/json": {
schema: resolver(MCP.Status),
},
},
},
...errors(400, 404),
},
}),
async (c) => {
const name = c.req.param("name")
const supportsOAuth = await MCP.supportsOAuth(name)
if (!supportsOAuth) {
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
}
const status = await MCP.authenticate(name)
return c.json(status)
},
)
.delete(
"/:name/auth",
describeRoute({
summary: "Remove MCP OAuth",
description: "Remove OAuth credentials for an MCP server",
operationId: "mcp.auth.remove",
responses: {
200: {
description: "OAuth credentials removed",
content: {
"application/json": {
schema: resolver(z.object({ success: z.literal(true) })),
},
},
},
...errors(404),
},
}),
async (c) => {
const name = c.req.param("name")
await MCP.removeAuth(name)
return c.json({ success: true as const })
},
)
.post(
"/:name/connect",
describeRoute({
description: "Connect an MCP server",
operationId: "mcp.connect",
responses: {
200: {
description: "MCP server connected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.connect(name)
return c.json(true)
},
)
.post(
"/:name/disconnect",
describeRoute({
description: "Disconnect an MCP server",
operationId: "mcp.disconnect",
responses: {
200: {
description: "MCP server disconnected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await MCP.disconnect(name)
return c.json(true)
},
),
)
@@ -0,0 +1,68 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PermissionRoutes = lazy(() =>
new Hono()
.post(
"/:requestID/reply",
describeRoute({
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.reply",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
return c.json(true)
},
)
.get(
"/",
describeRoute({
summary: "List pending permissions",
description: "Get all pending permission requests across all sessions.",
operationId: "permission.list",
responses: {
200: {
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(PermissionNext.Request.array()),
},
},
},
},
}),
async (c) => {
const permissions = await PermissionNext.list()
return c.json(permissions)
},
),
)
@@ -0,0 +1,82 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ProjectRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List all projects",
description: "Get a list of projects that have been opened with OpenCode.",
operationId: "project.list",
responses: {
200: {
description: "List of projects",
content: {
"application/json": {
schema: resolver(Project.Info.array()),
},
},
},
},
}),
async (c) => {
const projects = await Project.list()
return c.json(projects)
},
)
.get(
"/current",
describeRoute({
summary: "Get current project",
description: "Retrieve the currently active project that OpenCode is working with.",
operationId: "project.current",
responses: {
200: {
description: "Current project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
},
}),
async (c) => {
return c.json(Instance.project)
},
)
.patch(
"/:projectID",
describeRoute({
summary: "Update project",
description: "Update project properties such as name, icon and color.",
operationId: "project.update",
responses: {
200: {
description: "Updated project information",
content: {
"application/json": {
schema: resolver(Project.Info),
},
},
},
...errors(400, 404),
},
}),
validator("param", z.object({ projectID: z.string() })),
validator("json", Project.update.schema.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await Project.update({ ...body, projectID })
return c.json(project)
},
),
)
@@ -0,0 +1,165 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Config } from "../../config/config"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { ProviderAuth } from "../../provider/auth"
import { mapValues } from "remeda"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const ProviderRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List providers",
description: "Get a list of all available AI providers, including both available and connected ones.",
operationId: "provider.list",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(
z.object({
all: ModelsDev.Provider.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),
),
},
},
},
},
}),
async (c) => {
const config = await Config.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = await ModelsDev.get()
const filteredProviders: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filteredProviders[key] = value
}
}
const connected = await Provider.list()
const providers = Object.assign(
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return c.json({
all: Object.values(providers),
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
connected: Object.keys(connected),
})
},
)
.get(
"/auth",
describeRoute({
summary: "Get provider auth methods",
description: "Retrieve available authentication methods for all AI providers.",
operationId: "provider.auth",
responses: {
200: {
description: "Provider auth methods",
content: {
"application/json": {
schema: resolver(z.record(z.string(), z.array(ProviderAuth.Method))),
},
},
},
},
}),
async (c) => {
return c.json(await ProviderAuth.methods())
},
)
.post(
"/:providerID/oauth/authorize",
describeRoute({
summary: "OAuth authorize",
description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.",
operationId: "provider.oauth.authorize",
responses: {
200: {
description: "Authorization URL and method",
content: {
"application/json": {
schema: resolver(ProviderAuth.Authorization.optional()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method } = c.req.valid("json")
const result = await ProviderAuth.authorize({
providerID,
method,
})
return c.json(result)
},
)
.post(
"/:providerID/oauth/callback",
describeRoute({
summary: "OAuth callback",
description: "Handle the OAuth callback from a provider after user authorization.",
operationId: "provider.oauth.callback",
responses: {
200: {
description: "OAuth callback processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"param",
z.object({
providerID: z.string().meta({ description: "Provider ID" }),
}),
),
validator(
"json",
z.object({
method: z.number().meta({ description: "Auth method index" }),
code: z.string().optional().meta({ description: "OAuth authorization code" }),
}),
),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await ProviderAuth.callback({
providerID,
method,
code,
})
return c.json(true)
},
),
)
+169
View File
@@ -0,0 +1,169 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
import { Storage } from "../../storage/storage"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const PtyRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List PTY sessions",
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
operationId: "pty.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Pty.Info.array()),
},
},
},
},
}),
async (c) => {
return c.json(Pty.list())
},
)
.post(
"/",
describeRoute({
summary: "Create PTY session",
description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.",
operationId: "pty.create",
responses: {
200: {
description: "Created session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("json", Pty.CreateInput),
async (c) => {
const info = await Pty.create(c.req.valid("json"))
return c.json(info)
},
)
.get(
"/:ptyID",
describeRoute({
summary: "Get PTY session",
description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.",
operationId: "pty.get",
responses: {
200: {
description: "Session info",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
throw new Storage.NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
)
.put(
"/:ptyID",
describeRoute({
summary: "Update PTY session",
description: "Update properties of an existing pseudo-terminal (PTY) session.",
operationId: "pty.update",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Pty.Info),
},
},
},
...errors(400),
},
}),
validator("param", z.object({ ptyID: z.string() })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await Pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
return c.json(info)
},
)
.delete(
"/:ptyID",
describeRoute({
summary: "Remove PTY session",
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
operationId: "pty.remove",
responses: {
200: {
description: "Session removed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
async (c) => {
await Pty.remove(c.req.valid("param").ptyID)
return c.json(true)
},
)
.get(
"/:ptyID/connect",
describeRoute({
summary: "Connect to PTY session",
description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
operationId: "pty.connect",
responses: {
200: {
description: "Connected session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(404),
},
}),
validator("param", z.object({ ptyID: z.string() })),
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws)
},
onMessage(event) {
handler?.onMessage(String(event.data))
},
onClose() {
handler?.onClose()
},
}
}),
),
)
@@ -0,0 +1,98 @@
import { Hono } from "hono"
import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { Question } from "../../question"
import z from "zod"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
export const QuestionRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
operationId: "question.list",
responses: {
200: {
description: "List of pending questions",
content: {
"application/json": {
schema: resolver(Question.Request.array()),
},
},
},
},
}),
async (c) => {
const questions = await Question.list()
return c.json(questions)
},
)
.post(
"/:requestID/reply",
describeRoute({
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
operationId: "question.reply",
responses: {
200: {
description: "Question answered successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", Question.Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await Question.reply({
requestID: params.requestID,
answers: json.answers,
})
return c.json(true)
},
)
.post(
"/:requestID/reject",
describeRoute({
summary: "Reject question request",
description: "Reject a question request from the AI assistant.",
operationId: "question.reject",
responses: {
200: {
description: "Question rejected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
async (c) => {
const params = c.req.valid("param")
await Question.reject(params.requestID)
return c.json(true)
},
),
)
@@ -0,0 +1,935 @@
import { Hono } from "hono"
import { stream } from "hono/streaming"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "../../session/prompt"
import { SessionCompaction } from "../../session/compaction"
import { SessionRevert } from "../../session/revert"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot"
import { Log } from "../../util/log"
import { PermissionNext } from "@/permission/next"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const log = Log.create({ service: "server" })
export const SessionRoutes = lazy(() =>
new Hono()
.get(
"/",
describeRoute({
summary: "List sessions",
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
operationId: "session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
},
}),
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
}),
),
async (c) => {
const query = c.req.valid("query")
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
sessions.push(session)
if (query.limit !== undefined && sessions.length >= query.limit) break
}
return c.json(sessions)
},
)
.get(
"/status",
describeRoute({
summary: "Get session status",
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
operationId: "session.status",
responses: {
200: {
description: "Get session status",
content: {
"application/json": {
schema: resolver(z.record(z.string(), SessionStatus.Info)),
},
},
},
...errors(400),
},
}),
async (c) => {
const result = SessionStatus.list()
return c.json(result)
},
)
.get(
"/:sessionID",
describeRoute({
summary: "Get session",
description: "Retrieve detailed information about a specific OpenCode session.",
tags: ["Session"],
operationId: "session.get",
responses: {
200: {
description: "Get session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.get.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("SEARCH", { url: c.req.url })
const session = await Session.get(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/children",
describeRoute({
summary: "Get session children",
tags: ["Session"],
description: "Retrieve all child sessions that were forked from the specified parent session.",
operationId: "session.children",
responses: {
200: {
description: "List of children",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.children.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await Session.children(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/todo",
describeRoute({
summary: "Get session todos",
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
operationId: "session.todo",
responses: {
200: {
description: "Todo list",
content: {
"application/json": {
schema: resolver(Todo.Info.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const todos = await Todo.get(sessionID)
return c.json(todos)
},
)
.post(
"/",
describeRoute({
summary: "Create session",
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
operationId: "session.create",
responses: {
...errors(400),
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
validator("json", Session.create.schema.optional()),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await Session.create(body)
return c.json(session)
},
)
.delete(
"/:sessionID",
describeRoute({
summary: "Delete session",
description: "Delete a session and permanently remove all associated data, including messages and history.",
operationId: "session.delete",
responses: {
200: {
description: "Successfully deleted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.remove.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.remove(sessionID)
return c.json(true)
},
)
.patch(
"/:sessionID",
describeRoute({
summary: "Update session",
description: "Update properties of an existing session, such as title or other metadata.",
operationId: "session.update",
responses: {
200: {
description: "Successfully updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
validator(
"json",
z.object({
title: z.string().optional(),
time: z
.object({
archived: z.number().optional(),
})
.optional(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const updatedSession = await Session.update(sessionID, (session) => {
if (updates.title !== undefined) {
session.title = updates.title
}
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
})
return c.json(updatedSession)
},
)
.post(
"/:sessionID/init",
describeRoute({
summary: "Initialize session",
description:
"Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
operationId: "session.init",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", Session.initialize.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await Session.initialize({ ...body, sessionID })
return c.json(true)
},
)
.post(
"/:sessionID/fork",
describeRoute({
summary: "Fork session",
description: "Create a new session by forking an existing session at a specific message point.",
operationId: "session.fork",
responses: {
200: {
description: "200",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
validator(
"param",
z.object({
sessionID: Session.fork.schema.shape.sessionID,
}),
),
validator("json", Session.fork.schema.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await Session.fork({ ...body, sessionID })
return c.json(result)
},
)
.post(
"/:sessionID/abort",
describeRoute({
summary: "Abort session",
description: "Abort an active session and stop any ongoing AI processing or command execution.",
operationId: "session.abort",
responses: {
200: {
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
SessionPrompt.cancel(c.req.valid("param").sessionID)
return c.json(true)
},
)
.post(
"/:sessionID/share",
describeRoute({
summary: "Share session",
description: "Create a shareable link for a session, allowing others to view the conversation.",
operationId: "session.share",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.share(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
)
.get(
"/:sessionID/diff",
describeRoute({
summary: "Get message diff",
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
operationId: "session.diff",
responses: {
200: {
description: "Successfully retrieved diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
},
},
},
},
}),
validator(
"param",
z.object({
sessionID: SessionSummary.diff.schema.shape.sessionID,
}),
),
validator(
"query",
z.object({
messageID: SessionSummary.diff.schema.shape.messageID,
}),
),
async (c) => {
const query = c.req.valid("query")
const params = c.req.valid("param")
const result = await SessionSummary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
})
return c.json(result)
},
)
.delete(
"/:sessionID/share",
describeRoute({
summary: "Unshare session",
description: "Remove the shareable link for a session, making it private again.",
operationId: "session.unshare",
responses: {
200: {
description: "Successfully unshared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: Session.unshare.schema,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await Session.unshare(sessionID)
const session = await Session.get(sessionID)
return c.json(session)
},
)
.post(
"/:sessionID/summarize",
describeRoute({
summary: "Summarize session",
description: "Generate a concise summary of the session using AI compaction to preserve key information.",
operationId: "session.summarize",
responses: {
200: {
description: "Summarized session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator(
"json",
z.object({
providerID: z.string(),
modelID: z.string(),
auto: z.boolean().optional().default(false),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = await Session.get(sessionID)
await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || (await Agent.defaultAgent())
break
}
}
await SessionCompaction.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
await SessionPrompt.loop(sessionID)
return c.json(true)
},
)
.get(
"/:sessionID/message",
describeRoute({
summary: "Get session messages",
description: "Retrieve all messages in a session, including user prompts and AI responses.",
operationId: "session.messages",
responses: {
200: {
description: "List of messages",
content: {
"application/json": {
schema: resolver(MessageV2.WithParts.array()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator(
"query",
z.object({
limit: z.coerce.number().optional(),
}),
),
async (c) => {
const query = c.req.valid("query")
const messages = await Session.messages({
sessionID: c.req.valid("param").sessionID,
limit: query.limit,
})
return c.json(messages)
},
)
.get(
"/:sessionID/message/:messageID",
describeRoute({
summary: "Get message",
description: "Retrieve a specific message from a session by its message ID.",
operationId: "session.message",
responses: {
200: {
description: "Message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Info,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
const message = await MessageV2.get({
sessionID: params.sessionID,
messageID: params.messageID,
})
return c.json(message)
},
)
.delete(
"/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Delete a part from a message",
operationId: "part.delete",
responses: {
200: {
description: "Successfully deleted part",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
await Session.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
return c.json(true)
},
)
.patch(
"/:sessionID/message/:messageID/part/:partID",
describeRoute({
description: "Update a part in a message",
operationId: "part.update",
responses: {
200: {
description: "Successfully updated part",
content: {
"application/json": {
schema: resolver(MessageV2.Part),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
messageID: z.string().meta({ description: "Message ID" }),
partID: z.string().meta({ description: "Part ID" }),
}),
),
validator("json", MessageV2.Part),
async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) {
throw new Error(
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await Session.updatePart(body)
return c.json(part)
},
)
.post(
"/:sessionID/message",
describeRoute({
summary: "Send message",
description: "Create and send a new message to a session, streaming the AI response.",
operationId: "session.prompt",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Assistant,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
c.status(200)
c.header("Content-Type", "application/json")
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.prompt({ ...body, sessionID })
stream.write(JSON.stringify(msg))
})
},
)
.post(
"/:sessionID/prompt_async",
describeRoute({
summary: "Send async message",
description:
"Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
operationId: "session.prompt_async",
responses: {
204: {
description: "Prompt accepted",
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
async (c) => {
c.status(204)
c.header("Content-Type", "application/json")
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
})
},
)
.post(
"/:sessionID/command",
describeRoute({
summary: "Send command",
description: "Send a new command to a session for execution by the AI assistant.",
operationId: "session.command",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Assistant,
parts: MessageV2.Part.array(),
}),
),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.command({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/:sessionID/shell",
describeRoute({
summary: "Run shell command",
description: "Execute a shell command within the session context and return the AI's response.",
operationId: "session.shell",
responses: {
200: {
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.Assistant),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string().meta({ description: "Session ID" }),
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await SessionPrompt.shell({ ...body, sessionID })
return c.json(msg)
},
)
.post(
"/:sessionID/revert",
describeRoute({
summary: "Revert message",
description: "Revert a specific message in a session, undoing its effects and restoring the previous state.",
operationId: "session.revert",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
validator("json", SessionRevert.RevertInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
const session = await SessionRevert.revert({
sessionID,
...c.req.valid("json"),
})
return c.json(session)
},
)
.post(
"/:sessionID/unrevert",
describeRoute({
summary: "Restore reverted messages",
description: "Restore all previously reverted messages in a session.",
operationId: "session.unrevert",
responses: {
200: {
description: "Updated session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await SessionRevert.unrevert({ sessionID })
return c.json(session)
},
)
.post(
"/:sessionID/permissions/:permissionID",
describeRoute({
summary: "Respond to permission",
deprecated: true,
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
sessionID: z.string(),
permissionID: z.string(),
}),
),
validator("json", z.object({ response: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
PermissionNext.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return c.json(true)
},
),
)
+377
View File
@@ -0,0 +1,377 @@
import { Hono, type Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { Bus } from "../../bus"
import { Session } from "../../session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
request.push({
path: ctx.req.path,
body,
})
return response.next()
}
const TuiControlRoutes = new Hono()
.get(
"/next",
describeRoute({
summary: "Get next TUI request",
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
operationId: "tui.control.next",
responses: {
200: {
description: "Next TUI request",
content: {
"application/json": {
schema: resolver(TuiRequest),
},
},
},
},
}),
async (c) => {
const req = await request.next()
return c.json(req)
},
)
.post(
"/response",
describeRoute({
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
operationId: "tui.control.response",
responses: {
200: {
description: "Response submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
response.push(body)
return c.json(true)
},
)
export const TuiRoutes = lazy(() =>
new Hono()
.post(
"/append-prompt",
describeRoute({
summary: "Append TUI prompt",
description: "Append prompt to the TUI",
operationId: "tui.appendPrompt",
responses: {
200: {
description: "Prompt processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", TuiEvent.PromptAppend.properties),
async (c) => {
await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/open-help",
describeRoute({
summary: "Open help dialog",
description: "Open the help dialog in the TUI to display user assistance information.",
operationId: "tui.openHelp",
responses: {
200: {
description: "Help dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "help.show",
})
return c.json(true)
},
)
.post(
"/open-sessions",
describeRoute({
summary: "Open sessions dialog",
description: "Open the session dialog",
operationId: "tui.openSessions",
responses: {
200: {
description: "Session dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/open-themes",
describeRoute({
summary: "Open themes dialog",
description: "Open the theme dialog",
operationId: "tui.openThemes",
responses: {
200: {
description: "Theme dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "session.list",
})
return c.json(true)
},
)
.post(
"/open-models",
describeRoute({
summary: "Open models dialog",
description: "Open the model dialog",
operationId: "tui.openModels",
responses: {
200: {
description: "Model dialog opened successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "model.list",
})
return c.json(true)
},
)
.post(
"/submit-prompt",
describeRoute({
summary: "Submit TUI prompt",
description: "Submit the prompt",
operationId: "tui.submitPrompt",
responses: {
200: {
description: "Prompt submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.submit",
})
return c.json(true)
},
)
.post(
"/clear-prompt",
describeRoute({
summary: "Clear TUI prompt",
description: "Clear the prompt",
operationId: "tui.clearPrompt",
responses: {
200: {
description: "Prompt cleared successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
await Bus.publish(TuiEvent.CommandExecute, {
command: "prompt.clear",
})
return c.json(true)
},
)
.post(
"/execute-command",
describeRoute({
summary: "Execute TUI command",
description: "Execute a TUI command (e.g. agent_cycle)",
operationId: "tui.executeCommand",
responses: {
200: {
description: "Command executed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator("json", z.object({ command: z.string() })),
async (c) => {
const command = c.req.valid("json").command
await Bus.publish(TuiEvent.CommandExecute, {
// @ts-expect-error
command: {
session_new: "session.new",
session_share: "session.share",
session_interrupt: "session.interrupt",
session_compact: "session.compact",
messages_page_up: "session.page.up",
messages_page_down: "session.page.down",
messages_half_page_up: "session.half.page.up",
messages_half_page_down: "session.half.page.down",
messages_first: "session.first",
messages_last: "session.last",
agent_cycle: "agent.cycle",
}[command],
})
return c.json(true)
},
)
.post(
"/show-toast",
describeRoute({
summary: "Show TUI toast",
description: "Show a toast notification in the TUI",
operationId: "tui.showToast",
responses: {
200: {
description: "Toast notification shown successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", TuiEvent.ToastShow.properties),
async (c) => {
await Bus.publish(TuiEvent.ToastShow, c.req.valid("json"))
return c.json(true)
},
)
.post(
"/publish",
describeRoute({
summary: "Publish TUI event",
description: "Publish a TUI event",
operationId: "tui.publish",
responses: {
200: {
description: "Event published successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.union(
Object.values(TuiEvent).map((def) => {
return z
.object({
type: z.literal(def.type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
})
}),
),
),
async (c) => {
const evt = c.req.valid("json")
await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)!, evt.properties)
return c.json(true)
},
)
.post(
"/select-session",
describeRoute({
summary: "Select session",
description: "Navigate the TUI to display the specified session.",
operationId: "tui.selectSession",
responses: {
200: {
description: "Session selected successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await Session.get(sessionID)
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},
)
.route("/control", TuiControlRoutes),
)
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
import { Hono, type Context } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import { z } from "zod"
import { AsyncQueue } from "../util/queue"
const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<any>()
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
request.push({
path: ctx.req.path,
body,
})
return response.next()
}
export const TuiRoute = new Hono()
.get(
"/next",
describeRoute({
summary: "Get next TUI request",
description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.",
operationId: "tui.control.next",
responses: {
200: {
description: "Next TUI request",
content: {
"application/json": {
schema: resolver(TuiRequest),
},
},
},
},
}),
async (c) => {
const req = await request.next()
return c.json(req)
},
)
.post(
"/response",
describeRoute({
summary: "Submit TUI response",
description: "Submit a response to the TUI request queue to complete a pending request.",
operationId: "tui.control.response",
responses: {
200: {
description: "Response submitted successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator("json", z.any()),
async (c) => {
const body = c.req.valid("json")
response.push(body)
return c.json(true)
},
)
+22 -16
View File
@@ -1,14 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import {
APICallError,
convertToModelMessages,
LoadAPIKeyError,
type ModelMessage,
type ToolSet,
type UIMessage,
} from "ai"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
@@ -439,7 +432,7 @@ export namespace MessageV2 {
})
export type WithParts = z.infer<typeof WithParts>
export function toModelMessage(input: WithParts[], options?: { tools?: ToolSet }): ModelMessage[] {
export function toModelMessage(input: WithParts[]): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
@@ -510,14 +503,30 @@ export namespace MessageV2 {
})
if (part.type === "tool") {
if (part.state.status === "completed") {
if (part.state.attachments?.length) {
result.push({
id: Identifier.ascending("message"),
role: "user",
parts: [
{
type: "text",
text: `The tool ${part.tool} returned the following attachments:`,
},
...part.state.attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
mediaType: attachment.mime,
filename: attachment.filename,
})),
],
})
}
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted
? { output: "[Old tool result content cleared]" }
: { output: part.state.output, attachments: part.state.attachments },
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
callProviderMetadata: part.metadata,
})
}
@@ -556,10 +565,7 @@ export namespace MessageV2 {
}
}
return convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{ tools: options?.tools },
)
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
+5 -33
View File
@@ -597,7 +597,7 @@ export namespace SessionPrompt {
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
messages: [
...MessageV2.toModelMessage(sessionMessages, { tools }),
...MessageV2.toModelMessage(sessionMessages),
...(isLastStep
? [
{
@@ -718,22 +718,8 @@ export namespace SessionPrompt {
},
toModelOutput(result) {
return {
type: "content",
value: [
{
type: "text",
text: result.output,
},
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
return {
type: "media",
data: base64,
mediaType: attachment.mime,
}
}) ?? []),
],
type: "text",
value: result.output,
}
},
})
@@ -822,22 +808,8 @@ export namespace SessionPrompt {
}
item.toModelOutput = (result) => {
return {
type: "content",
value: [
{
type: "text",
text: result.output,
},
...(result.attachments?.map((attachment: MessageV2.FilePart) => {
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
return {
type: "media",
data: base64,
mediaType: attachment.mime,
}
}) ?? []),
],
type: "text",
value: result.output,
}
}
tools[key] = item
@@ -1,75 +0,0 @@
import { test, expect, describe, afterEach } from "bun:test"
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
describe("McpOAuthCallback.ensureRunning", () => {
afterEach(async () => {
await McpOAuthCallback.stop()
})
test("starts server with default config when no redirectUri provided", async () => {
await McpOAuthCallback.ensureRunning()
expect(McpOAuthCallback.isRunning()).toBe(true)
})
test("starts server with custom redirectUri", async () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
expect(McpOAuthCallback.isRunning()).toBe(true)
})
test("is idempotent when called with same redirectUri", async () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback")
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback")
expect(McpOAuthCallback.isRunning()).toBe(true)
})
test("restarts server when redirectUri changes", async () => {
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1")
expect(McpOAuthCallback.isRunning()).toBe(true)
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2")
expect(McpOAuthCallback.isRunning()).toBe(true)
})
test("isRunning returns false when not started", async () => {
expect(McpOAuthCallback.isRunning()).toBe(false)
})
test("isRunning returns false after stop", async () => {
await McpOAuthCallback.ensureRunning()
await McpOAuthCallback.stop()
expect(McpOAuthCallback.isRunning()).toBe(false)
})
})
describe("parseRedirectUri", () => {
test("returns defaults when no URI provided", () => {
const result = parseRedirectUri()
expect(result.port).toBe(19876)
expect(result.path).toBe("/mcp/oauth/callback")
})
test("parses port and path from URI", () => {
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
expect(result.port).toBe(8080)
expect(result.path).toBe("/oauth/callback")
})
test("defaults to port 80 for http without explicit port", () => {
const result = parseRedirectUri("http://127.0.0.1/callback")
expect(result.port).toBe(80)
expect(result.path).toBe("/callback")
})
test("defaults to port 443 for https without explicit port", () => {
const result = parseRedirectUri("https://127.0.0.1/callback")
expect(result.port).toBe(443)
expect(result.path).toBe("/callback")
})
test("returns defaults for invalid URI", () => {
const result = parseRedirectUri("not-a-valid-url")
expect(result.port).toBe(19876)
expect(result.path).toBe("/mcp/oauth/callback")
})
})
+105 -23
View File
@@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
headers: {},
} as any
test("strips itemId and reasoningEncryptedContent when store=false", () => {
test("preserves itemId and reasoningEncryptedContent when store=false", () => {
const msgs = [
{
role: "assistant",
@@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
expect(result).toHaveLength(1)
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123")
expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456")
})
test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => {
test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => {
const zenModel = {
...openaiModel,
providerID: "zen",
@@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[]
expect(result).toHaveLength(1)
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123")
expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456")
})
test("preserves other openai options when stripping itemId", () => {
test("preserves other openai options including itemId", () => {
const msgs = [
{
role: "assistant",
@@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123")
expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value")
})
test("strips metadata for openai package even when store is true", () => {
test("preserves metadata for openai package when store is true", () => {
const msgs = [
{
role: "assistant",
@@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
},
] as any[]
// openai package always strips itemId regardless of store value
// openai package preserves itemId regardless of store value
const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123")
})
test("strips metadata for non-openai packages when store is false", () => {
test("preserves metadata for non-openai packages when store is false", () => {
const anthropicModel = {
...openaiModel,
providerID: "anthropic",
@@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
},
] as any[]
// store=false triggers stripping even for non-openai packages
// store=false preserves metadata for non-openai packages
const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[]
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123")
})
test("strips metadata using providerID key when store is false", () => {
test("preserves metadata using providerID key when store is false", () => {
const opencodeModel = {
...openaiModel,
providerID: "opencode",
@@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[]
expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123")
expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value")
})
test("strips itemId across all providerOptions keys", () => {
test("preserves itemId across all providerOptions keys", () => {
const opencodeModel = {
...openaiModel,
providerID: "opencode",
@@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[]
expect(result[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined()
expect(result[0].providerOptions?.extra?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined()
expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined()
expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root")
expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode")
expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra")
expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part")
expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part")
expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part")
})
test("does not strip metadata for non-openai packages when store is not false", () => {
@@ -914,6 +914,88 @@ describe("ProviderTransform.message - strip openai metadata when store=false", (
})
})
describe("ProviderTransform.message - providerOptions key remapping", () => {
const createModel = (providerID: string, npm: string) =>
({
id: `${providerID}/test-model`,
providerID,
api: {
id: "test-model",
url: "https://api.test.com",
npm,
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
limit: { context: 128000, output: 8192 },
status: "active",
options: {},
headers: {},
}) as any
test("azure keeps 'azure' key and does not remap to 'openai'", () => {
const model = createModel("azure", "@ai-sdk/azure")
const msgs = [
{
role: "user",
content: "Hello",
providerOptions: {
azure: { someOption: "value" },
},
},
] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" })
expect(result[0].providerOptions?.openai).toBeUndefined()
})
test("openai with github-copilot npm remaps providerID to 'openai'", () => {
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
const msgs = [
{
role: "user",
content: "Hello",
providerOptions: {
"github-copilot": { someOption: "value" },
},
},
] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" })
expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined()
})
test("bedrock remaps providerID to 'bedrock' key", () => {
const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock")
const msgs = [
{
role: "user",
content: "Hello",
providerOptions: {
"my-bedrock": { someOption: "value" },
},
},
] as any[]
const result = ProviderTransform.message(msgs, model, {})
expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" })
expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined()
})
})
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
@@ -1,35 +1,8 @@
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
import type { ToolSet } from "ai"
const sessionID = "session"
// Mock tool that transforms output to content format with media support
function createMockTools(): ToolSet {
return {
bash: {
description: "mock bash tool",
inputSchema: { type: "object", properties: {} } as any,
toModelOutput(result: { output: string; attachments?: MessageV2.FilePart[] }) {
return {
type: "content" as const,
value: [
{ type: "text" as const, text: result.output },
...(result.attachments?.map((attachment) => {
const base64 = attachment.url.startsWith("data:") ? attachment.url.split(",", 2)[1] : attachment.url
return {
type: "media" as const,
data: base64,
mediaType: attachment.mime,
}
}) ?? []),
],
}
},
},
} as ToolSet
}
function userInfo(id: string): MessageV2.User {
return {
id,
@@ -286,11 +259,23 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
expect(MessageV2.toModelMessage(input)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
},
{
role: "user",
content: [
{ type: "text", text: "The tool bash returned the following attachments:" },
{
type: "file",
mediaType: "image/png",
filename: "attachment.png",
data: "https://example.com/attachment.png",
},
],
},
{
role: "assistant",
content: [
@@ -312,13 +297,7 @@ describe("session.message-v2.toModelMessage", () => {
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: {
type: "content",
value: [
{ type: "text", text: "ok" },
{ type: "media", data: "https://example.com/attachment.png", mediaType: "image/png" },
],
},
output: { type: "text", value: "ok" },
providerOptions: { openai: { tool: "meta" } },
},
],
@@ -362,7 +341,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(MessageV2.toModelMessage(input, { tools: createMockTools() })).toStrictEqual([
expect(MessageV2.toModelMessage(input)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -386,10 +365,7 @@ describe("session.message-v2.toModelMessage", () => {
type: "tool-result",
toolCallId: "call-1",
toolName: "bash",
output: {
type: "content",
value: [{ type: "text", text: "[Old tool result content cleared]" }],
},
output: { type: "text", value: "[Old tool result content cleared]" },
},
],
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {
+257 -238
View File
@@ -7,6 +7,7 @@ import type {
AppAgentsResponses,
AppLogErrors,
AppLogResponses,
AppSkillsResponses,
Auth as Auth3,
AuthSetErrors,
AuthSetResponses,
@@ -100,7 +101,6 @@ import type {
SessionCreateResponses,
SessionDeleteErrors,
SessionDeleteResponses,
SessionDiffErrors,
SessionDiffResponses,
SessionForkResponses,
SessionGetErrors,
@@ -653,48 +653,6 @@ export class Tool extends HeyApiClient {
}
}
export class Instance extends HeyApiClient {
/**
* Dispose instance
*
* Clean up and dispose the current OpenCode instance, releasing all resources.
*/
public dispose<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
url: "/instance/dispose",
...options,
...params,
})
}
}
export class Path extends HeyApiClient {
/**
* Get paths
*
* Retrieve the current working directory and related path information for the OpenCode instance.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<PathGetResponses, unknown, ThrowOnError>({
url: "/path",
...options,
...params,
})
}
}
export class Worktree extends HeyApiClient {
/**
* List worktrees
@@ -751,27 +709,34 @@ export class Worktree extends HeyApiClient {
}
}
export class Vcs extends HeyApiClient {
export class Resource extends HeyApiClient {
/**
* Get VCS info
* Get MCP resources
*
* Retrieve version control system (VCS) information for the current project, such as git branch.
* Get all available MCP resources from connected servers. Optionally filter by name.
*/
public get<ThrowOnError extends boolean = false>(
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<VcsGetResponses, unknown, ThrowOnError>({
url: "/vcs",
return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
url: "/experimental/resource",
...options,
...params,
})
}
}
export class Experimental extends HeyApiClient {
private _resource?: Resource
get resource(): Resource {
return (this._resource ??= new Resource({ client: this.client }))
}
}
export class Session extends HeyApiClient {
/**
* List sessions
@@ -1197,9 +1162,9 @@ export class Session extends HeyApiClient {
}
/**
* Get session diff
* Get message diff
*
* Get all file changes (diffs) made during this session.
* Get the file changes (diff) that resulted from a specific user message in the session.
*/
public diff<ThrowOnError extends boolean = false>(
parameters: {
@@ -1221,7 +1186,7 @@ export class Session extends HeyApiClient {
},
],
)
return (options?.client ?? this.client).get<SessionDiffResponses, SessionDiffErrors, ThrowOnError>({
return (options?.client ?? this.client).get<SessionDiffResponses, unknown, ThrowOnError>({
url: "/session/{sessionID}/diff",
...options,
...params,
@@ -1877,27 +1842,6 @@ export class Question extends HeyApiClient {
}
}
export class Command extends HeyApiClient {
/**
* List commands
*
* Get a list of all available commands in the OpenCode system.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<CommandListResponses, unknown, ThrowOnError>({
url: "/command",
...options,
...params,
})
}
}
export class Oauth extends HeyApiClient {
/**
* OAuth authorize
@@ -2208,70 +2152,6 @@ export class File extends HeyApiClient {
}
}
export class App extends HeyApiClient {
/**
* Write log
*
* Write a log entry to the server logs with specified level and metadata.
*/
public log<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
service?: string
level?: "debug" | "info" | "error" | "warn"
message?: string
extra?: {
[key: string]: unknown
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "body", key: "service" },
{ in: "body", key: "level" },
{ in: "body", key: "message" },
{ in: "body", key: "extra" },
],
},
],
)
return (options?.client ?? this.client).post<AppLogResponses, AppLogErrors, ThrowOnError>({
url: "/log",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List agents
*
* Get a list of all available AI agents in the OpenCode system.
*/
public agents<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
url: "/agent",
...options,
...params,
})
}
}
export class Auth extends HeyApiClient {
/**
* Remove MCP OAuth
@@ -2522,76 +2402,6 @@ export class Mcp extends HeyApiClient {
}
}
export class Resource extends HeyApiClient {
/**
* Get MCP resources
*
* Get all available MCP resources from connected servers. Optionally filter by name.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<ExperimentalResourceListResponses, unknown, ThrowOnError>({
url: "/experimental/resource",
...options,
...params,
})
}
}
export class Experimental extends HeyApiClient {
private _resource?: Resource
get resource(): Resource {
return (this._resource ??= new Resource({ client: this.client }))
}
}
export class Lsp extends HeyApiClient {
/**
* Get LSP status
*
* Get LSP server status
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<LspStatusResponses, unknown, ThrowOnError>({
url: "/lsp",
...options,
...params,
})
}
}
export class Formatter extends HeyApiClient {
/**
* Get formatter status
*
* Get formatter status
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<FormatterStatusResponses, unknown, ThrowOnError>({
url: "/formatter",
...options,
...params,
})
}
}
export class Control extends HeyApiClient {
/**
* Get next TUI request
@@ -2930,6 +2740,215 @@ export class Tui extends HeyApiClient {
}
}
export class Instance extends HeyApiClient {
/**
* Dispose instance
*
* Clean up and dispose the current OpenCode instance, releasing all resources.
*/
public dispose<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).post<InstanceDisposeResponses, unknown, ThrowOnError>({
url: "/instance/dispose",
...options,
...params,
})
}
}
export class Path extends HeyApiClient {
/**
* Get paths
*
* Retrieve the current working directory and related path information for the OpenCode instance.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<PathGetResponses, unknown, ThrowOnError>({
url: "/path",
...options,
...params,
})
}
}
export class Vcs extends HeyApiClient {
/**
* Get VCS info
*
* Retrieve version control system (VCS) information for the current project, such as git branch.
*/
public get<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<VcsGetResponses, unknown, ThrowOnError>({
url: "/vcs",
...options,
...params,
})
}
}
export class Command extends HeyApiClient {
/**
* List commands
*
* Get a list of all available commands in the OpenCode system.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<CommandListResponses, unknown, ThrowOnError>({
url: "/command",
...options,
...params,
})
}
}
export class App extends HeyApiClient {
/**
* Write log
*
* Write a log entry to the server logs with specified level and metadata.
*/
public log<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
service?: string
level?: "debug" | "info" | "error" | "warn"
message?: string
extra?: {
[key: string]: unknown
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "body", key: "service" },
{ in: "body", key: "level" },
{ in: "body", key: "message" },
{ in: "body", key: "extra" },
],
},
],
)
return (options?.client ?? this.client).post<AppLogResponses, AppLogErrors, ThrowOnError>({
url: "/log",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* List agents
*
* Get a list of all available AI agents in the OpenCode system.
*/
public agents<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<AppAgentsResponses, unknown, ThrowOnError>({
url: "/agent",
...options,
...params,
})
}
/**
* List skills
*
* Get a list of all available skills in the OpenCode system.
*/
public skills<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<AppSkillsResponses, unknown, ThrowOnError>({
url: "/skill",
...options,
...params,
})
}
}
export class Lsp extends HeyApiClient {
/**
* Get LSP status
*
* Get LSP server status
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<LspStatusResponses, unknown, ThrowOnError>({
url: "/lsp",
...options,
...params,
})
}
}
export class Formatter extends HeyApiClient {
/**
* Get formatter status
*
* Get formatter status
*/
public status<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<FormatterStatusResponses, unknown, ThrowOnError>({
url: "/formatter",
...options,
...params,
})
}
}
export class Auth2 extends HeyApiClient {
/**
* Set auth credentials
@@ -3023,24 +3042,14 @@ export class OpencodeClient extends HeyApiClient {
return (this._tool ??= new Tool({ client: this.client }))
}
private _instance?: Instance
get instance(): Instance {
return (this._instance ??= new Instance({ client: this.client }))
}
private _path?: Path
get path(): Path {
return (this._path ??= new Path({ client: this.client }))
}
private _worktree?: Worktree
get worktree(): Worktree {
return (this._worktree ??= new Worktree({ client: this.client }))
}
private _vcs?: Vcs
get vcs(): Vcs {
return (this._vcs ??= new Vcs({ client: this.client }))
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
}
private _session?: Session
@@ -3063,11 +3072,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._question ??= new Question({ client: this.client }))
}
private _command?: Command
get command(): Command {
return (this._command ??= new Command({ client: this.client }))
}
private _provider?: Provider
get provider(): Provider {
return (this._provider ??= new Provider({ client: this.client }))
@@ -3083,19 +3087,39 @@ export class OpencodeClient extends HeyApiClient {
return (this._file ??= new File({ client: this.client }))
}
private _app?: App
get app(): App {
return (this._app ??= new App({ client: this.client }))
}
private _mcp?: Mcp
get mcp(): Mcp {
return (this._mcp ??= new Mcp({ client: this.client }))
}
private _experimental?: Experimental
get experimental(): Experimental {
return (this._experimental ??= new Experimental({ client: this.client }))
private _tui?: Tui
get tui(): Tui {
return (this._tui ??= new Tui({ client: this.client }))
}
private _instance?: Instance
get instance(): Instance {
return (this._instance ??= new Instance({ client: this.client }))
}
private _path?: Path
get path(): Path {
return (this._path ??= new Path({ client: this.client }))
}
private _vcs?: Vcs
get vcs(): Vcs {
return (this._vcs ??= new Vcs({ client: this.client }))
}
private _command?: Command
get command(): Command {
return (this._command ??= new Command({ client: this.client }))
}
private _app?: App
get app(): App {
return (this._app ??= new App({ client: this.client }))
}
private _lsp?: Lsp
@@ -3108,11 +3132,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._formatter ??= new Formatter({ client: this.client }))
}
private _tui?: Tui
get tui(): Tui {
return (this._tui ??= new Tui({ client: this.client }))
}
private _auth?: Auth2
get auth(): Auth2 {
return (this._auth ??= new Auth2({ client: this.client }))
+357 -355
View File
@@ -62,6 +62,13 @@ export type EventLspUpdated = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type FileDiff = {
file: string
before: string
@@ -599,13 +606,6 @@ export type EventSessionCompacted = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type Todo = {
/**
* Brief description of the task
@@ -843,15 +843,15 @@ export type EventPtyDeleted = {
}
}
export type EventServerConnected = {
type: "server.connected"
export type EventGlobalDisposed = {
type: "global.disposed"
properties: {
[key: string]: unknown
}
}
export type EventGlobalDisposed = {
type: "global.disposed"
export type EventServerConnected = {
type: "server.connected"
properties: {
[key: string]: unknown
}
@@ -864,6 +864,7 @@ export type Event =
| EventServerInstanceDisposed
| EventLspClientDiagnostics
| EventLspUpdated
| EventFileEdited
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -876,7 +877,6 @@ export type Event =
| EventQuestionReplied
| EventQuestionRejected
| EventSessionCompacted
| EventFileEdited
| EventTodoUpdated
| EventTuiPromptAppend
| EventTuiCommandExecute
@@ -896,8 +896,8 @@ export type Event =
| EventPtyUpdated
| EventPtyExited
| EventPtyDeleted
| EventServerConnected
| EventGlobalDisposed
| EventServerConnected
export type GlobalEvent = {
directory: string
@@ -1530,10 +1530,6 @@ export type McpOAuthConfig = {
* OAuth scopes to request during authorization
*/
scope?: string
/**
* OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).
*/
redirectUri?: string
}
export type McpRemoteConfig = {
@@ -1796,98 +1792,6 @@ export type Config = {
}
}
export type ToolIds = Array<string>
export type ToolListItem = {
id: string
description: string
parameters: unknown
}
export type ToolList = Array<ToolListItem>
export type Path = {
home: string
state: string
config: string
worktree: string
directory: string
}
export type Worktree = {
name: string
branch: string
directory: string
}
export type WorktreeCreateInput = {
name?: string
startCommand?: string
}
export type VcsInfo = {
branch: string
}
export type TextPartInput = {
id?: string
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
time?: {
start: number
end?: number
}
metadata?: {
[key: string]: unknown
}
}
export type FilePartInput = {
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}
export type AgentPartInput = {
id?: string
type: "agent"
name: string
source?: {
value: string
start: number
end: number
}
}
export type SubtaskPartInput = {
id?: string
type: "subtask"
prompt: string
description: string
agent: string
model?: {
providerID: string
modelID: string
}
command?: string
}
export type Command = {
name: string
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: Array<string>
}
export type Model = {
id: string
providerID: string
@@ -1973,6 +1877,83 @@ export type Provider = {
}
}
export type ToolIds = Array<string>
export type ToolListItem = {
id: string
description: string
parameters: unknown
}
export type ToolList = Array<ToolListItem>
export type Worktree = {
name: string
branch: string
directory: string
}
export type WorktreeCreateInput = {
name?: string
startCommand?: string
}
export type McpResource = {
name: string
uri: string
description?: string
mimeType?: string
client: string
}
export type TextPartInput = {
id?: string
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
time?: {
start: number
end?: number
}
metadata?: {
[key: string]: unknown
}
}
export type FilePartInput = {
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}
export type AgentPartInput = {
id?: string
type: "agent"
name: string
source?: {
value: string
start: number
end: number
}
}
export type SubtaskPartInput = {
id?: string
type: "subtask"
prompt: string
description: string
agent: string
model?: {
providerID: string
modelID: string
}
command?: string
}
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
@@ -2030,27 +2011,6 @@ export type File = {
status: "added" | "deleted" | "modified"
}
export type Agent = {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
permission: PermissionRuleset
model?: {
modelID: string
providerID: string
}
prompt?: string
options: {
[key: string]: unknown
}
steps?: number
}
export type McpStatusConnected = {
status: "connected"
}
@@ -2080,12 +2040,48 @@ export type McpStatus =
| McpStatusNeedsAuth
| McpStatusNeedsClientRegistration
export type McpResource = {
export type Path = {
home: string
state: string
config: string
worktree: string
directory: string
}
export type VcsInfo = {
branch: string
}
export type Command = {
name: string
uri: string
description?: string
mimeType?: string
client: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
hints: Array<string>
}
export type Agent = {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
permission: PermissionRuleset
model?: {
modelID: string
providerID: string
}
prompt?: string
options: {
[key: string]: unknown
}
steps?: number
}
export type LspStatus = {
@@ -2469,6 +2465,29 @@ export type ConfigUpdateResponses = {
export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses]
export type ConfigProvidersData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/config/providers"
}
export type ConfigProvidersResponses = {
/**
* List of providers
*/
200: {
providers: Array<Provider>
default: {
[key: string]: string
}
}
}
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ToolIdsData = {
body?: never
path?: never
@@ -2525,42 +2544,6 @@ export type ToolListResponses = {
export type ToolListResponse = ToolListResponses[keyof ToolListResponses]
export type InstanceDisposeData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/instance/dispose"
}
export type InstanceDisposeResponses = {
/**
* Instance disposed
*/
200: boolean
}
export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]
export type PathGetData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/path"
}
export type PathGetResponses = {
/**
* Path
*/
200: Path
}
export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
export type WorktreeListData = {
body?: never
path?: never
@@ -2606,23 +2589,26 @@ export type WorktreeCreateResponses = {
export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
export type VcsGetData = {
export type ExperimentalResourceListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/vcs"
url: "/experimental/resource"
}
export type VcsGetResponses = {
export type ExperimentalResourceListResponses = {
/**
* VCS info
* MCP resources
*/
200: VcsInfo
200: {
[key: string]: McpResource
}
}
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
export type ExperimentalResourceListResponse =
ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses]
export type SessionListData = {
body?: never
@@ -3058,9 +3044,6 @@ export type SessionShareResponse = SessionShareResponses[keyof SessionShareRespo
export type SessionDiffData = {
body?: never
path: {
/**
* Session ID
*/
sessionID: string
}
query?: {
@@ -3070,22 +3053,9 @@ export type SessionDiffData = {
url: "/session/{sessionID}/diff"
}
export type SessionDiffErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* Not found
*/
404: NotFoundError
}
export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors]
export type SessionDiffResponses = {
/**
* List of diffs
* Successfully retrieved diff
*/
200: Array<FileDiff>
}
@@ -3757,47 +3727,6 @@ export type QuestionRejectResponses = {
export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
export type CommandListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/command"
}
export type CommandListResponses = {
/**
* List of commands
*/
200: Array<Command>
}
export type CommandListResponse = CommandListResponses[keyof CommandListResponses]
export type ConfigProvidersData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/config/providers"
}
export type ConfigProvidersResponses = {
/**
* List of providers
*/
200: {
providers: Array<Provider>
default: {
[key: string]: string
}
}
}
export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]
export type ProviderListData = {
body?: never
path?: never
@@ -4112,70 +4041,6 @@ export type FileStatusResponses = {
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
export type AppLogData = {
body?: {
/**
* Service name for the log entry
*/
service: string
/**
* Log level
*/
level: "debug" | "info" | "error" | "warn"
/**
* Log message
*/
message: string
/**
* Additional metadata for the log entry
*/
extra?: {
[key: string]: unknown
}
}
path?: never
query?: {
directory?: string
}
url: "/log"
}
export type AppLogErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type AppLogError = AppLogErrors[keyof AppLogErrors]
export type AppLogResponses = {
/**
* Log entry written successfully
*/
200: boolean
}
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
export type AppAgentsData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/agent"
}
export type AppAgentsResponses = {
/**
* List of agents
*/
200: Array<Agent>
}
export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]
export type McpStatusData = {
body?: never
path?: never
@@ -4408,63 +4273,6 @@ export type McpDisconnectResponses = {
export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]
export type ExperimentalResourceListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/experimental/resource"
}
export type ExperimentalResourceListResponses = {
/**
* MCP resources
*/
200: {
[key: string]: McpResource
}
}
export type ExperimentalResourceListResponse =
ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses]
export type LspStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/lsp"
}
export type LspStatusResponses = {
/**
* LSP server status
*/
200: Array<LspStatus>
}
export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
export type FormatterStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/formatter"
}
export type FormatterStatusResponses = {
/**
* Formatter status
*/
200: Array<FormatterStatus>
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
export type TuiAppendPromptData = {
body?: {
text: string
@@ -4759,6 +4567,200 @@ export type TuiControlResponseResponses = {
export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses]
export type InstanceDisposeData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/instance/dispose"
}
export type InstanceDisposeResponses = {
/**
* Instance disposed
*/
200: boolean
}
export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]
export type PathGetData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/path"
}
export type PathGetResponses = {
/**
* Path
*/
200: Path
}
export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
export type VcsGetData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/vcs"
}
export type VcsGetResponses = {
/**
* VCS info
*/
200: VcsInfo
}
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
export type CommandListData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/command"
}
export type CommandListResponses = {
/**
* List of commands
*/
200: Array<Command>
}
export type CommandListResponse = CommandListResponses[keyof CommandListResponses]
export type AppLogData = {
body?: {
/**
* Service name for the log entry
*/
service: string
/**
* Log level
*/
level: "debug" | "info" | "error" | "warn"
/**
* Log message
*/
message: string
/**
* Additional metadata for the log entry
*/
extra?: {
[key: string]: unknown
}
}
path?: never
query?: {
directory?: string
}
url: "/log"
}
export type AppLogErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type AppLogError = AppLogErrors[keyof AppLogErrors]
export type AppLogResponses = {
/**
* Log entry written successfully
*/
200: boolean
}
export type AppLogResponse = AppLogResponses[keyof AppLogResponses]
export type AppAgentsData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/agent"
}
export type AppAgentsResponses = {
/**
* List of agents
*/
200: Array<Agent>
}
export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]
export type AppSkillsData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/skill"
}
export type AppSkillsResponses = {
/**
* List of skills
*/
200: Array<{
name: string
description: string
location: string
}>
}
export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses]
export type LspStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/lsp"
}
export type LspStatusResponses = {
/**
* LSP server status
*/
200: Array<LspStatus>
}
export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
export type FormatterStatusData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/formatter"
}
export type FormatterStatusResponses = {
/**
* Formatter status
*/
200: Array<FormatterStatus>
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
export type AuthSetData = {
body?: Auth
path: {
+800 -775
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.23",
"version": "1.1.25",
"type": "module",
"license": "MIT",
"exports": {
@@ -59,6 +59,7 @@
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"strip-ansi": "7.1.2",
"virtua": "catalog:"
}
}
+1 -2
View File
@@ -16,7 +16,6 @@
[data-component="dialog"] {
position: fixed;
inset: 0;
margin-left: var(--dialog-left-margin);
z-index: 50;
display: flex;
align-items: center;
@@ -25,7 +24,7 @@
[data-slot="dialog-container"] {
position: relative;
z-index: 50;
width: min(calc(100vw - 16px), 480px);
width: min(calc(100vw - 16px), 640px);
height: min(calc(100vh - 16px), 512px);
display: flex;
flex-direction: column;
@@ -0,0 +1,17 @@
[data-component="inline-input"] {
color: inherit;
background: transparent;
border: 0;
border-radius: var(--radius-md);
padding: 0;
min-width: 0;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
box-sizing: border-box;
&:focus {
outline: none;
box-shadow: 0 0 0 1px var(--border-interactive-focus);
}
}
@@ -0,0 +1,11 @@
import type { ComponentProps } from "solid-js"
import { splitProps } from "solid-js"
export type InlineInputProps = ComponentProps<"input"> & {
width?: string
}
export function InlineInput(props: InlineInputProps) {
const [local, others] = splitProps(props, ["class", "width"])
return <input data-component="inline-input" class={local.class} style={{ width: local.width }} {...others} />
}
+18
View File
@@ -0,0 +1,18 @@
[data-component="keybind"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 20px;
padding: 0 8px;
border-radius: 2px;
background: var(--surface-base);
box-shadow: var(--shadow-xxs-border);
/* text-12-medium */
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--text-weak);
}
+20
View File
@@ -0,0 +1,20 @@
import type { ComponentProps, ParentProps } from "solid-js"
export interface KeybindProps extends ParentProps {
class?: string
classList?: ComponentProps<"span">["classList"]
}
export function Keybind(props: KeybindProps) {
return (
<span
data-component="keybind"
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
>
{props.children}
</span>
)
}
+14 -4
View File
@@ -56,11 +56,14 @@
width: 20px;
height: 20px;
background-color: transparent;
opacity: 0.5;
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
@@ -91,7 +94,7 @@
[data-slot="list-empty-state"] {
display: flex;
padding: 32px 0px;
padding: 32px 48px;
flex-direction: column;
justify-content: center;
align-items: center;
@@ -103,8 +106,9 @@
justify-content: center;
align-items: center;
gap: 2px;
max-width: 100%;
color: var(--text-weak);
text-align: center;
white-space: nowrap;
/* text-14-regular */
font-family: var(--font-family-sans);
@@ -117,6 +121,8 @@
[data-slot="list-filter"] {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
}
}
@@ -125,10 +131,14 @@
display: flex;
flex-direction: column;
&:last-child {
padding-bottom: 12px;
}
[data-slot="list-header"] {
display: flex;
z-index: 10;
padding: 0 12px 8px 8px;
padding: 8px 12px 8px 12px;
justify-content: space-between;
align-items: center;
align-self: stretch;
@@ -136,7 +146,7 @@
position: sticky;
top: 0;
color: var(--text-base);
color: var(--text-weak);
/* text-14-medium */
font-family: var(--font-family-sans);
+9 -5
View File
@@ -8,6 +8,8 @@ import { TextField } from "./text-field"
export interface ListSearchProps {
placeholder?: string
autofocus?: boolean
hideIcon?: boolean
class?: string
}
export interface ListProps<T> extends FilteredListProps<T> {
@@ -67,7 +69,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
const element = scrollRef()?.querySelector(`[data-key="${key}"]`)
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`)
element?.scrollIntoView({ block: "center" })
})
})
@@ -79,8 +81,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
scrollRef()?.scrollTo(0, 0)
return
}
const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
element?.scrollIntoView({ block: "center", behavior: "smooth" })
const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`)
element?.scrollIntoView({ block: "center" })
})
createEffect(() => {
@@ -146,9 +148,11 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
return (
<div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
<Show when={!!props.search}>
<div data-slot="list-search">
<div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
<div data-slot="list-search-container">
<Icon name="magnifying-glass" />
<Show when={!searchProps().hideIcon}>
<Icon name="magnifying-glass" />
</Show>
<TextField
autofocus={searchProps().autofocus}
variant="ghost"

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