Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f49967518 | ||
|
|
ec637aa21e | ||
|
|
2ff9a757b6 | ||
|
|
cb8533ef5b | ||
|
|
cdbb009ab0 | ||
|
|
001b486356 | ||
|
|
d315026abc | ||
|
|
d1191675c6 | ||
|
|
362a657b4f | ||
|
|
0276885181 | ||
|
|
5a38a6f248 | ||
|
|
aef01003e7 | ||
|
|
a38e1701ee | ||
|
|
bf9ee32d4a | ||
|
|
0917991361 | ||
|
|
c6a241e331 | ||
|
|
4b7301e8ca | ||
|
|
1bf20f0a2b | ||
|
|
e3b4d4ad49 | ||
|
|
6b207b09d6 | ||
|
|
9771325026 | ||
|
|
bbd1c071c4 | ||
|
|
8e9a0c4ad0 | ||
|
|
ced093e646 | ||
|
|
283bdce358 | ||
|
|
91d5ce8bf3 | ||
|
|
7f870cc9d4 | ||
|
|
2cb3b0484b | ||
|
|
11b0df6b86 | ||
|
|
e15af828fa | ||
|
|
265cbaea7c | ||
|
|
d39ebbc947 | ||
|
|
06acd70670 | ||
|
|
c285304acf |
@@ -44,6 +44,8 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "Check for duplicate PRs related to this new PR:"
|
echo "Check for duplicate PRs related to this new PR:"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
|
||||||
|
echo ""
|
||||||
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
|
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Description:"
|
echo "Description:"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stats:
|
stats:
|
||||||
if: github.repository == 'sst/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ You are a duplicate PR detection agent. When a PR is opened, your job is to sear
|
|||||||
|
|
||||||
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
|
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
|
||||||
|
|
||||||
|
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
|
||||||
|
|
||||||
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
|
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
|
||||||
|
|
||||||
If you find potential duplicates:
|
If you find potential duplicates:
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export default tool({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prs = result.items as PR[]
|
const prs = result.items as PR[]
|
||||||
|
|
||||||
|
if (prs.length === 0) {
|
||||||
|
return `No other PRs found matching "${args.query}"`
|
||||||
|
}
|
||||||
|
|
||||||
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
|
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
|
||||||
|
|
||||||
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
|
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
|
||||||
|
|||||||
@@ -189,3 +189,6 @@
|
|||||||
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||||
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
||||||
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||||
|
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
|
||||||
|
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
||||||
|
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.88.1",
|
"@hey-api/openapi-ts": "0.88.1",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -393,7 +393,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -442,7 +442,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,19 @@ Usage: install.sh [options]
|
|||||||
Options:
|
Options:
|
||||||
-h, --help Display this help message
|
-h, --help Display this help message
|
||||||
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||||
|
-b, --binary <path> Install from a local binary instead of downloading
|
||||||
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
curl -fsSL https://opencode.ai/install | bash
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||||
|
./install --binary /path/to/opencode
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
requested_version=${VERSION:-}
|
requested_version=${VERSION:-}
|
||||||
no_modify_path=false
|
no_modify_path=false
|
||||||
|
binary_path=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
-b|--binary)
|
||||||
|
if [[ -n "${2:-}" ]]; then
|
||||||
|
binary_path="$2"
|
||||||
|
shift 2
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: --binary requires a path argument${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
--no-modify-path)
|
--no-modify-path)
|
||||||
no_modify_path=true
|
no_modify_path=true
|
||||||
shift
|
shift
|
||||||
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
raw_os=$(uname -s)
|
|
||||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$raw_os" in
|
|
||||||
Darwin*) os="darwin" ;;
|
|
||||||
Linux*) os="linux" ;;
|
|
||||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
arch=$(uname -m)
|
|
||||||
if [[ "$arch" == "aarch64" ]]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
if [[ "$arch" == "x86_64" ]]; then
|
|
||||||
arch="x64"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
|
||||||
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
|
||||||
if [ "$rosetta_flag" = "1" ]; then
|
|
||||||
arch="arm64"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
combo="$os-$arch"
|
|
||||||
case "$combo" in
|
|
||||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
archive_ext=".zip"
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
archive_ext=".tar.gz"
|
|
||||||
fi
|
|
||||||
|
|
||||||
is_musl=false
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if [ -f /etc/alpine-release ]; then
|
|
||||||
is_musl=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v ldd >/dev/null 2>&1; then
|
|
||||||
if ldd --version 2>&1 | grep -qi musl; then
|
|
||||||
is_musl=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
needs_baseline=false
|
|
||||||
if [ "$arch" = "x64" ]; then
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
|
||||||
needs_baseline=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$os" = "darwin" ]; then
|
|
||||||
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
|
||||||
if [ "$avx2" != "1" ]; then
|
|
||||||
needs_baseline=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
target="$os-$arch"
|
|
||||||
if [ "$needs_baseline" = "true" ]; then
|
|
||||||
target="$target-baseline"
|
|
||||||
fi
|
|
||||||
if [ "$is_musl" = "true" ]; then
|
|
||||||
target="$target-musl"
|
|
||||||
fi
|
|
||||||
|
|
||||||
filename="$APP-$target$archive_ext"
|
|
||||||
|
|
||||||
|
|
||||||
if [ "$os" = "linux" ]; then
|
|
||||||
if ! command -v tar >/dev/null 2>&1; then
|
|
||||||
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if ! command -v unzip >/dev/null 2>&1; then
|
|
||||||
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
INSTALL_DIR=$HOME/.opencode/bin
|
INSTALL_DIR=$HOME/.opencode/bin
|
||||||
mkdir -p "$INSTALL_DIR"
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
if [ -z "$requested_version" ]; then
|
# If --binary is provided, skip all download/detection logic
|
||||||
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
if [ -n "$binary_path" ]; then
|
||||||
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
if [ ! -f "$binary_path" ]; then
|
||||||
|
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
|
||||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
|
||||||
echo -e "${RED}Failed to fetch version information${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
specific_version="local"
|
||||||
else
|
else
|
||||||
# Strip leading 'v' if present
|
raw_os=$(uname -s)
|
||||||
requested_version="${requested_version#v}"
|
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||||
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
case "$raw_os" in
|
||||||
specific_version=$requested_version
|
Darwin*) os="darwin" ;;
|
||||||
|
Linux*) os="linux" ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# Verify the release exists before downloading
|
arch=$(uname -m)
|
||||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
if [[ "$arch" == "aarch64" ]]; then
|
||||||
if [ "$http_status" = "404" ]; then
|
arch="arm64"
|
||||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
fi
|
||||||
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
if [[ "$arch" == "x86_64" ]]; then
|
||||||
|
arch="x64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
||||||
|
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
||||||
|
if [ "$rosetta_flag" = "1" ]; then
|
||||||
|
arch="arm64"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
combo="$os-$arch"
|
||||||
|
case "$combo" in
|
||||||
|
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive_ext=".zip"
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
archive_ext=".tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_musl=false
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if [ -f /etc/alpine-release ]; then
|
||||||
|
is_musl=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v ldd >/dev/null 2>&1; then
|
||||||
|
if ldd --version 2>&1 | grep -qi musl; then
|
||||||
|
is_musl=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
needs_baseline=false
|
||||||
|
if [ "$arch" = "x64" ]; then
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$os" = "darwin" ]; then
|
||||||
|
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
||||||
|
if [ "$avx2" != "1" ]; then
|
||||||
|
needs_baseline=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
target="$os-$arch"
|
||||||
|
if [ "$needs_baseline" = "true" ]; then
|
||||||
|
target="$target-baseline"
|
||||||
|
fi
|
||||||
|
if [ "$is_musl" = "true" ]; then
|
||||||
|
target="$target-musl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
filename="$APP-$target$archive_ext"
|
||||||
|
|
||||||
|
|
||||||
|
if [ "$os" = "linux" ]; then
|
||||||
|
if ! command -v tar >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if ! command -v unzip >/dev/null 2>&1; then
|
||||||
|
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$requested_version" ]; then
|
||||||
|
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
||||||
|
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||||
|
|
||||||
|
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||||
|
echo -e "${RED}Failed to fetch version information${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Strip leading 'v' if present
|
||||||
|
requested_version="${requested_version#v}"
|
||||||
|
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
||||||
|
specific_version=$requested_version
|
||||||
|
|
||||||
|
# Verify the release exists before downloading
|
||||||
|
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
||||||
|
if [ "$http_status" = "404" ]; then
|
||||||
|
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||||
|
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -313,8 +334,18 @@ download_and_install() {
|
|||||||
rm -rf "$tmp_dir"
|
rm -rf "$tmp_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
check_version
|
install_from_binary() {
|
||||||
download_and_install
|
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
|
||||||
|
cp "$binary_path" "${INSTALL_DIR}/opencode"
|
||||||
|
chmod 755 "${INSTALL_DIR}/opencode"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -n "$binary_path" ]; then
|
||||||
|
install_from_binary
|
||||||
|
else
|
||||||
|
check_version
|
||||||
|
download_and_install
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
add_to_path() {
|
add_to_path() {
|
||||||
@@ -416,4 +447,3 @@ echo -e ""
|
|||||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
||||||
echo -e ""
|
echo -e ""
|
||||||
echo -e ""
|
echo -e ""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
|
|||||||
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("serialized output should restore after Terminal.reset()", async () => {
|
||||||
|
const { term, addon } = createTerminal()
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
"\x1b[1;32m❯\x1b[0m \x1b[34mcd\x1b[0m /some/path",
|
||||||
|
"\x1b[1;32m❯\x1b[0m \x1b[34mls\x1b[0m -la",
|
||||||
|
"total 42",
|
||||||
|
].join("\r\n")
|
||||||
|
|
||||||
|
await writeAndWait(term, content)
|
||||||
|
|
||||||
|
const serialized = addon.serialize()
|
||||||
|
|
||||||
|
const { term: term2 } = createTerminal()
|
||||||
|
terminals.push(term2)
|
||||||
|
term2.reset()
|
||||||
|
await writeAndWait(term2, serialized)
|
||||||
|
|
||||||
|
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
|
||||||
|
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
|
||||||
|
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("alternate buffer should round-trip without garbage", async () => {
|
||||||
|
const { term, addon } = createTerminal(20, 5)
|
||||||
|
|
||||||
|
await writeAndWait(term, "normal\r\n")
|
||||||
|
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
|
||||||
|
|
||||||
|
expect(term.buffer.active.type).toBe("alternate")
|
||||||
|
|
||||||
|
const serialized = addon.serialize()
|
||||||
|
|
||||||
|
const { term: term2 } = createTerminal(20, 5)
|
||||||
|
terminals.push(term2)
|
||||||
|
await writeAndWait(term2, serialized)
|
||||||
|
|
||||||
|
expect(term2.buffer.active.type).toBe("alternate")
|
||||||
|
|
||||||
|
const line = term2.buffer.active.getLine(0)
|
||||||
|
expect(line?.translateToString(true)).toBe("ALT")
|
||||||
|
|
||||||
|
// Ensure a cell beyond content isn't garbage
|
||||||
|
const cellCode = line?.getCell(10)?.getCode()
|
||||||
|
expect(cellCode === 0 || cellCode === 32).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("serialized output written to new terminal should match original colors", async () => {
|
test("serialized output written to new terminal should match original colors", async () => {
|
||||||
const { term, addon } = createTerminal(40, 5)
|
const { term, addon } = createTerminal(40, 5)
|
||||||
|
|
||||||
|
|||||||
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
|
|||||||
abstract class BaseSerializeHandler {
|
abstract class BaseSerializeHandler {
|
||||||
constructor(protected readonly _buffer: IBuffer) {}
|
constructor(protected readonly _buffer: IBuffer) {}
|
||||||
|
|
||||||
private _isRealContent(codepoint: number): boolean {
|
|
||||||
if (codepoint === 0) return false
|
|
||||||
if (codepoint >= 0xf000) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private _findLastContentColumn(line: IBufferLine): number {
|
|
||||||
let lastContent = -1
|
|
||||||
for (let col = 0; col < line.length; col++) {
|
|
||||||
const cell = line.getCell(col)
|
|
||||||
if (cell && this._isRealContent(cell.getCode())) {
|
|
||||||
lastContent = col
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastContent + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
|
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
|
||||||
let oldCell = this._buffer.getNullCell()
|
let oldCell = this._buffer.getNullCell()
|
||||||
|
|
||||||
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
|
|||||||
const startColumn = range.start.x
|
const startColumn = range.start.x
|
||||||
const endColumn = range.end.x
|
const endColumn = range.end.x
|
||||||
|
|
||||||
this._beforeSerialize(endRow - startRow, startRow, endRow)
|
this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
|
||||||
|
|
||||||
for (let row = startRow; row <= endRow; row++) {
|
for (let row = startRow; row <= endRow; row++) {
|
||||||
const line = this._buffer.getLine(row)
|
const line = this._buffer.getLine(row)
|
||||||
if (line) {
|
if (line) {
|
||||||
const startLineColumn = row === range.start.y ? startColumn : 0
|
const startLineColumn = row === range.start.y ? startColumn : 0
|
||||||
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
|
const endLineColumn = Math.min(endColumn, line.length)
|
||||||
const endLineColumn = Math.min(maxColumn, line.length)
|
|
||||||
for (let col = startLineColumn; col < endLineColumn; col++) {
|
for (let col = startLineColumn; col < endLineColumn; col++) {
|
||||||
const c = line.getCell(col)
|
const c = line.getCell(col)
|
||||||
if (!c) {
|
if (!c) {
|
||||||
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
protected _beforeSerialize(rows: number, start: number, _end: number): void {
|
||||||
this._allRows = new Array<string>(rows)
|
this._allRows = new Array<string>(rows)
|
||||||
|
this._allRowSeparators = new Array<string>(rows)
|
||||||
|
this._rowIndex = 0
|
||||||
|
|
||||||
|
this._currentRow = ""
|
||||||
|
this._nullCellCount = 0
|
||||||
|
this._cursorStyle = this._buffer.getNullCell()
|
||||||
|
|
||||||
this._lastContentCursorRow = start
|
this._lastContentCursorRow = start
|
||||||
this._lastCursorRow = start
|
this._lastCursorRow = start
|
||||||
this._firstRow = start
|
this._firstRow = start
|
||||||
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||||
let rowSeparator = ""
|
let rowSeparator = ""
|
||||||
|
|
||||||
|
if (this._nullCellCount > 0) {
|
||||||
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
|
this._nullCellCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
if (!isLastRow) {
|
if (!isLastRow) {
|
||||||
const nextLine = this._buffer.getLine(row + 1)
|
const nextLine = this._buffer.getLine(row + 1)
|
||||||
|
|
||||||
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codepoint = cell.getCode()
|
const codepoint = cell.getCode()
|
||||||
const isGarbage = codepoint >= 0xf000
|
const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
|
||||||
|
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
|
||||||
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
|
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
|
||||||
|
|
||||||
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
|
||||||
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
|
|
||||||
if (styleChanged) {
|
if (styleChanged) {
|
||||||
if (this._nullCellCount > 0) {
|
if (this._nullCellCount > 0) {
|
||||||
this._currentRow += `\u001b[${this._nullCellCount}C`
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
this._nullCellCount = 0
|
this._nullCellCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
|||||||
this._nullCellCount += cell.getWidth()
|
this._nullCellCount += cell.getWidth()
|
||||||
} else {
|
} else {
|
||||||
if (this._nullCellCount > 0) {
|
if (this._nullCellCount > 0) {
|
||||||
this._currentRow += `\u001b[${this._nullCellCount}C`
|
this._currentRow += " ".repeat(this._nullCellCount)
|
||||||
this._nullCellCount = 0
|
this._nullCellCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
import {
|
||||||
|
createEffect,
|
||||||
|
on,
|
||||||
|
Component,
|
||||||
|
Show,
|
||||||
|
For,
|
||||||
|
onMount,
|
||||||
|
onCleanup,
|
||||||
|
Switch,
|
||||||
|
Match,
|
||||||
|
createMemo,
|
||||||
|
createSignal,
|
||||||
|
} from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
|
const imageAttachments = createMemo(
|
||||||
|
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
|
||||||
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
popover: "at" | "slash" | null
|
popover: "at" | "slash" | null
|
||||||
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
savedPrompt: Prompt | null
|
savedPrompt: Prompt | null
|
||||||
placeholder: number
|
placeholder: number
|
||||||
dragging: boolean
|
dragging: boolean
|
||||||
imageAttachments: ImageAttachmentPart[]
|
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
applyingHistory: boolean
|
applyingHistory: boolean
|
||||||
}>({
|
}>({
|
||||||
@@ -169,7 +183,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
savedPrompt: null,
|
savedPrompt: null,
|
||||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||||
dragging: false,
|
dragging: false,
|
||||||
imageAttachments: [],
|
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
})
|
})
|
||||||
@@ -246,6 +259,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
const isFocused = createFocusSignal(() => editorRef)
|
||||||
|
const [composing, setComposing] = createSignal(false)
|
||||||
|
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||||
|
|
||||||
const addImageAttachment = async (file: File) => {
|
const addImageAttachment = async (file: File) => {
|
||||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||||
@@ -260,21 +275,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
mime: file.type,
|
mime: file.type,
|
||||||
dataUrl,
|
dataUrl,
|
||||||
}
|
}
|
||||||
setStore(
|
const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
|
||||||
produce((draft) => {
|
prompt.set([...prompt.current(), attachment], cursorPosition)
|
||||||
draft.imageAttachments.push(attachment)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeImageAttachment = (id: string) => {
|
const removeImageAttachment = (id: string) => {
|
||||||
setStore(
|
const current = prompt.current()
|
||||||
produce((draft) => {
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
|
prompt.set(next, prompt.cursor())
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
@@ -302,6 +312,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
// relatedTarget is null when leaving the document window
|
// relatedTarget is null when leaving the document window
|
||||||
if (!event.relatedTarget) {
|
if (!event.relatedTarget) {
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDrop = async (event: DragEvent) => {
|
const handleGlobalDrop = async (event: DragEvent) => {
|
||||||
|
if (dialog.active) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStore("dragging", false)
|
setStore("dragging", false)
|
||||||
|
|
||||||
@@ -518,8 +534,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
on(
|
on(
|
||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(currentParts) => {
|
||||||
|
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
|
||||||
const domParts = parseFromDOM()
|
const domParts = parseFromDOM()
|
||||||
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
|
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
let cursorPosition: number | null = null
|
let cursorPosition: number | null = null
|
||||||
@@ -527,7 +544,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
cursorPosition = getCursorPosition(editorRef)
|
cursorPosition = getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderEditor(currentParts)
|
renderEditor(inputParts)
|
||||||
|
|
||||||
if (cursorPosition !== null) {
|
if (cursorPosition !== null) {
|
||||||
setCursorPosition(editorRef, cursorPosition)
|
setCursorPosition(editorRef, cursorPosition)
|
||||||
@@ -618,11 +635,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleInput = () => {
|
const handleInput = () => {
|
||||||
const rawParts = parseFromDOM()
|
const rawParts = parseFromDOM()
|
||||||
|
const images = imageAttachments()
|
||||||
const cursorPosition = getCursorPosition(editorRef)
|
const cursorPosition = getCursorPosition(editorRef)
|
||||||
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
|
||||||
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
const trimmed = rawText.replace(/\u200B/g, "").trim()
|
||||||
const hasNonText = rawParts.some((part) => part.type !== "text")
|
const hasNonText = rawParts.some((part) => part.type !== "text")
|
||||||
const shouldReset = trimmed.length === 0 && !hasNonText
|
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
|
||||||
|
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
@@ -661,7 +679,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setStore("savedPrompt", null)
|
setStore("savedPrompt", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt.set(rawParts, cursorPosition)
|
prompt.set([...rawParts, ...images], cursorPosition)
|
||||||
queueScroll()
|
queueScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,16 +782,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
.map((p) => ("content" in p ? p.content : ""))
|
.map((p) => ("content" in p ? p.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
.trim()
|
.trim()
|
||||||
if (!text) return
|
const hasImages = prompt.some((part) => part.type === "image")
|
||||||
|
if (!text && !hasImages) return
|
||||||
|
|
||||||
const entry = clonePromptParts(prompt)
|
const entry = clonePromptParts(prompt)
|
||||||
const currentHistory = mode === "shell" ? shellHistory : history
|
const currentHistory = mode === "shell" ? shellHistory : history
|
||||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||||
const lastEntry = currentHistory.entries[0]
|
const lastEntry = currentHistory.entries[0]
|
||||||
if (lastEntry) {
|
if (lastEntry && isPromptEqual(lastEntry, entry)) return
|
||||||
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
|
|
||||||
if (lastText === text) return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
|
||||||
}
|
}
|
||||||
@@ -862,6 +878,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && isImeComposing(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
atOnKeyDown(event)
|
atOnKeyDown(event)
|
||||||
@@ -943,7 +963,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const currentPrompt = prompt.current()
|
const currentPrompt = prompt.current()
|
||||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
const images = store.imageAttachments.slice()
|
const images = imageAttachments().slice()
|
||||||
const mode = store.mode
|
const mode = store.mode
|
||||||
|
|
||||||
if (text.trim().length === 0 && images.length === 0) {
|
if (text.trim().length === 0 && images.length === 0) {
|
||||||
@@ -1037,14 +1057,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
prompt.reset()
|
prompt.reset()
|
||||||
setStore("imageAttachments", [])
|
|
||||||
setStore("mode", "normal")
|
setStore("mode", "normal")
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreInput = () => {
|
const restoreInput = () => {
|
||||||
prompt.set(currentPrompt, promptLength(currentPrompt))
|
prompt.set(currentPrompt, promptLength(currentPrompt))
|
||||||
setStore("imageAttachments", images)
|
|
||||||
setStore("mode", mode)
|
setStore("mode", mode)
|
||||||
setStore("popover", null)
|
setStore("popover", null)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -1087,6 +1105,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
agent,
|
agent,
|
||||||
model: `${model.providerID}/${model.modelID}`,
|
model: `${model.providerID}/${model.modelID}`,
|
||||||
variant,
|
variant,
|
||||||
|
parts: images.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: attachment.mime,
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
filename: attachment.filename,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -1440,9 +1465,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={store.imageAttachments.length > 0}>
|
<Show when={imageAttachments().length > 0}>
|
||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
<For each={store.imageAttachments}>
|
<For each={imageAttachments()}>
|
||||||
{(attachment) => (
|
{(attachment) => (
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<Show
|
<Show
|
||||||
@@ -1483,6 +1508,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
|
onCompositionStart={() => setComposing(true)}
|
||||||
|
onCompositionEnd={() => setComposing(false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"select-text": true,
|
||||||
@@ -1492,7 +1519,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
|
<Show when={!prompt.dirty()}>
|
||||||
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
|
||||||
{store.mode === "shell"
|
{store.mode === "shell"
|
||||||
? "Enter shell command..."
|
? "Enter shell command..."
|
||||||
@@ -1625,7 +1652,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
|
disabled={!prompt.dirty() && !working()}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="h-6 w-4.5"
|
class="h-6 w-4.5"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
import { createMemo, createResource, Show } from "solid-js"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
|
|||||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
@@ -36,6 +37,7 @@ export function SessionHeader() {
|
|||||||
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
||||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
|
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
||||||
|
|
||||||
function navigateToProject(directory: string) {
|
function navigateToProject(directory: string) {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
@@ -60,7 +62,7 @@ export function SessionHeader() {
|
|||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
<div class="hidden xl:flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
options={layout.projects.list().map((project) => project.worktree)}
|
options={worktrees()}
|
||||||
current={sync.project?.worktree ?? projectDirectory()}
|
current={sync.project?.worktree ?? projectDirectory()}
|
||||||
label={(x) => getFilename(x)}
|
label={(x) => getFilename(x)}
|
||||||
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
||||||
|
|||||||
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
if (options().includes(selection)) return selection
|
if (options().includes(selection)) return selection
|
||||||
return MAIN_WORKTREE
|
return MAIN_WORKTREE
|
||||||
})
|
})
|
||||||
|
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
|
||||||
|
const isWorktree = createMemo(() => {
|
||||||
|
const project = sync.project
|
||||||
|
if (!project) return false
|
||||||
|
return sync.data.path.directory !== project.worktree
|
||||||
|
})
|
||||||
|
|
||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
|
if (isWorktree()) return "Main branch"
|
||||||
const branch = sync.data.vcs?.branch
|
const branch = sync.data.vcs?.branch
|
||||||
if (branch) return `Current branch (${branch})`
|
if (branch) return `Main branch (${branch})`
|
||||||
return "Main branch"
|
return "Main branch"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
|
<div
|
||||||
|
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
|
||||||
|
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
|
||||||
|
>
|
||||||
<div class="text-20-medium text-text-weaker">New session</div>
|
<div class="text-20-medium text-text-weaker">New session</div>
|
||||||
<div class="flex justify-center items-center gap-3">
|
<div class="flex justify-center items-center gap-3">
|
||||||
<Icon name="folder" size="small" />
|
<Icon name="folder" size="small" />
|
||||||
<div class="text-12-medium text-text-weak">
|
<div class="text-12-medium text-text-weak">
|
||||||
{getDirectory(sync.data.path.directory)}
|
{getDirectory(projectRoot())}
|
||||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center items-center gap-1">
|
<div class="flex justify-center items-center gap-1">
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||||
let ws: WebSocket
|
let ws: WebSocket | undefined
|
||||||
let term: Term
|
let term: Term | undefined
|
||||||
let ghostty: Ghostty
|
let ghostty: Ghostty
|
||||||
let serializeAddon: SerializeAddon
|
let serializeAddon: SerializeAddon
|
||||||
let fitAddon: FitAddon
|
let fitAddon: FitAddon
|
||||||
let handleResize: () => void
|
let handleResize: () => void
|
||||||
|
let reconnect: number | undefined
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
const getTerminalColors = (): TerminalColors => {
|
const getTerminalColors = (): TerminalColors => {
|
||||||
const mode = theme.mode()
|
const mode = theme.mode()
|
||||||
@@ -76,22 +78,24 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
if (!term || !term.hasSelection()) return false
|
if (!term || !term.hasSelection()) return false
|
||||||
const selection = term.getSelection()
|
const selection = term.getSelection()
|
||||||
if (!selection) return false
|
if (!selection) return false
|
||||||
|
if (document.body) {
|
||||||
|
const textarea = document.createElement("textarea")
|
||||||
|
textarea.value = selection
|
||||||
|
textarea.setAttribute("readonly", "")
|
||||||
|
textarea.style.position = "fixed"
|
||||||
|
textarea.style.opacity = "0"
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
const copied = document.execCommand("copy")
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
if (copied) return true
|
||||||
|
}
|
||||||
const clipboard = navigator.clipboard
|
const clipboard = navigator.clipboard
|
||||||
if (clipboard?.writeText) {
|
if (clipboard?.writeText) {
|
||||||
clipboard.writeText(selection).catch(() => {})
|
clipboard.writeText(selection).catch(() => {})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (!document.body) return false
|
return false
|
||||||
const textarea = document.createElement("textarea")
|
|
||||||
textarea.value = selection
|
|
||||||
textarea.setAttribute("readonly", "")
|
|
||||||
textarea.style.position = "fixed"
|
|
||||||
textarea.style.opacity = "0"
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
const copied = document.execCommand("copy")
|
|
||||||
document.body.removeChild(textarea)
|
|
||||||
return copied
|
|
||||||
}
|
}
|
||||||
const handlePointerDown = () => {
|
const handlePointerDown = () => {
|
||||||
const activeElement = document.activeElement
|
const activeElement = document.activeElement
|
||||||
@@ -104,8 +108,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
ghostty = await Ghostty.load()
|
ghostty = await Ghostty.load()
|
||||||
|
|
||||||
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
|
const socket = new WebSocket(
|
||||||
term = new Term({
|
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
|
||||||
|
)
|
||||||
|
ws = socket
|
||||||
|
|
||||||
|
const t = new Term({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: "IBM Plex Mono, monospace",
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
@@ -114,7 +122,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
scrollback: 10_000,
|
scrollback: 10_000,
|
||||||
ghostty,
|
ghostty,
|
||||||
})
|
})
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term = t
|
||||||
|
|
||||||
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
if (key === "c") {
|
if (key === "c") {
|
||||||
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
|
||||||
@@ -134,30 +144,31 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
|
|
||||||
fitAddon = new FitAddon()
|
fitAddon = new FitAddon()
|
||||||
serializeAddon = new SerializeAddon()
|
serializeAddon = new SerializeAddon()
|
||||||
term.loadAddon(serializeAddon)
|
t.loadAddon(serializeAddon)
|
||||||
term.loadAddon(fitAddon)
|
t.loadAddon(fitAddon)
|
||||||
|
|
||||||
term.open(container)
|
t.open(container)
|
||||||
container.addEventListener("pointerdown", handlePointerDown)
|
container.addEventListener("pointerdown", handlePointerDown)
|
||||||
focusTerminal()
|
focusTerminal()
|
||||||
|
|
||||||
if (local.pty.buffer) {
|
if (local.pty.buffer) {
|
||||||
if (local.pty.rows && local.pty.cols) {
|
if (local.pty.rows && local.pty.cols) {
|
||||||
term.resize(local.pty.cols, local.pty.rows)
|
t.resize(local.pty.cols, local.pty.rows)
|
||||||
}
|
}
|
||||||
term.reset()
|
t.reset()
|
||||||
term.write(local.pty.buffer)
|
t.write(local.pty.buffer, () => {
|
||||||
if (local.pty.scrollY) {
|
if (local.pty.scrollY) {
|
||||||
term.scrollToLine(local.pty.scrollY)
|
t.scrollToLine(local.pty.scrollY)
|
||||||
}
|
}
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fitAddon.observeResize()
|
fitAddon.observeResize()
|
||||||
handleResize = () => fitAddon.fit()
|
handleResize = () => fitAddon.fit()
|
||||||
window.addEventListener("resize", handleResize)
|
window.addEventListener("resize", handleResize)
|
||||||
term.onResize(async (size) => {
|
t.onResize(async (size) => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
await sdk.client.pty
|
await sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
@@ -169,39 +180,39 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
term.onData((data) => {
|
t.onData((data) => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
ws.send(data)
|
socket.send(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
term.onKey((key) => {
|
t.onKey((key) => {
|
||||||
if (key.key == "Enter") {
|
if (key.key == "Enter") {
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// term.onScroll((ydisp) => {
|
// t.onScroll((ydisp) => {
|
||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
ws.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
console.log("WebSocket connected")
|
console.log("WebSocket connected")
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
size: {
|
size: {
|
||||||
cols: term.cols,
|
cols: t.cols,
|
||||||
rows: term.rows,
|
rows: t.rows,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
})
|
})
|
||||||
ws.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
term.write(event.data)
|
t.write(event.data)
|
||||||
})
|
})
|
||||||
ws.addEventListener("error", (error) => {
|
socket.addEventListener("error", (error) => {
|
||||||
console.error("WebSocket error:", error)
|
console.error("WebSocket error:", error)
|
||||||
props.onConnectError?.(error)
|
props.onConnectError?.(error)
|
||||||
})
|
})
|
||||||
ws.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
console.log("WebSocket disconnected")
|
console.log("WebSocket disconnected")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -211,18 +222,21 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
window.removeEventListener("resize", handleResize)
|
window.removeEventListener("resize", handleResize)
|
||||||
}
|
}
|
||||||
container.removeEventListener("pointerdown", handlePointerDown)
|
container.removeEventListener("pointerdown", handlePointerDown)
|
||||||
if (serializeAddon && props.onCleanup) {
|
|
||||||
|
const t = term
|
||||||
|
if (serializeAddon && props.onCleanup && t) {
|
||||||
const buffer = serializeAddon.serialize()
|
const buffer = serializeAddon.serialize()
|
||||||
props.onCleanup({
|
props.onCleanup({
|
||||||
...local.pty,
|
...local.pty,
|
||||||
buffer,
|
buffer,
|
||||||
rows: term.rows,
|
rows: t.rows,
|
||||||
cols: term.cols,
|
cols: t.cols,
|
||||||
scrollY: term.getViewportY(),
|
scrollY: t.getViewportY(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
ws?.close()
|
ws?.close()
|
||||||
term?.dispose()
|
t?.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { onCleanup } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
[key: string]: Event
|
[key: string]: Event
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
type Queued = { directory: string; payload: Event }
|
||||||
|
|
||||||
|
let queue: Array<Queued | undefined> = []
|
||||||
|
const coalesced = new Map<string, number>()
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let last = 0
|
||||||
|
|
||||||
|
const key = (directory: string, payload: Event) => {
|
||||||
|
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||||
|
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||||
|
if (payload.type === "message.part.updated") {
|
||||||
|
const part = payload.properties.part
|
||||||
|
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (timer) clearTimeout(timer)
|
||||||
|
timer = undefined
|
||||||
|
|
||||||
|
const events = queue
|
||||||
|
queue = []
|
||||||
|
coalesced.clear()
|
||||||
|
if (events.length === 0) return
|
||||||
|
|
||||||
|
last = Date.now()
|
||||||
|
batch(() => {
|
||||||
|
for (const event of events) {
|
||||||
|
if (!event) continue
|
||||||
|
emitter.emit(event.directory, event.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (timer) return
|
||||||
|
const elapsed = Date.now() - last
|
||||||
|
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const events = await eventSdk.global.event()
|
const events = await eventSdk.global.event()
|
||||||
|
let yielded = Date.now()
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
emitter.emit(event.directory ?? "global", event.payload)
|
const directory = event.directory ?? "global"
|
||||||
}
|
const payload = event.payload
|
||||||
})().catch(() => undefined)
|
const k = key(directory, payload)
|
||||||
|
if (k) {
|
||||||
|
const i = coalesced.get(k)
|
||||||
|
if (i !== undefined) {
|
||||||
|
queue[i] = undefined
|
||||||
|
}
|
||||||
|
coalesced.set(k, queue.length)
|
||||||
|
}
|
||||||
|
queue.push({ directory, payload })
|
||||||
|
schedule()
|
||||||
|
|
||||||
onCleanup(() => abort.abort())
|
if (Date.now() - yielded < 8) continue
|
||||||
|
yielded = Date.now()
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.finally(stop)
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
abort.abort()
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
|
|||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { ErrorPage, type InitError } from "../pages/error"
|
import { ErrorPage, type InitError } from "../pages/error"
|
||||||
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
|
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ function createGlobalSync() {
|
|||||||
.catch((e) => setGlobalStore("error", e))
|
.catch((e) => setGlobalStore("error", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
|
||||||
@@ -404,6 +404,7 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const health = await globalSDK.client.global
|
const health = await globalSDK.client.global
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createMemo, onMount } from "solid-js"
|
import { batch, createEffect, createMemo, onMount } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||||
@@ -23,13 +24,6 @@ export function getAvatarColors(key?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
|
||||||
if (a === b) return true
|
|
||||||
if (!a || !b) return false
|
|
||||||
if (a.length !== b.length) return false
|
|
||||||
return a.every((x, i) => x === b[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionTabs = {
|
type SessionTabs = {
|
||||||
active?: string
|
active?: string
|
||||||
all: string[]
|
all: string[]
|
||||||
@@ -97,8 +91,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...project,
|
|
||||||
...(metadata ?? {}),
|
...(metadata ?? {}),
|
||||||
|
...project,
|
||||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -115,6 +109,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roots = createMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const project of globalSync.data.project) {
|
||||||
|
const sandboxes = project.sandboxes ?? []
|
||||||
|
for (const sandbox of sandboxes) {
|
||||||
|
map.set(sandbox, project.worktree)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const map = roots()
|
||||||
|
if (map.size === 0) return
|
||||||
|
|
||||||
|
const projects = server.projects.list()
|
||||||
|
const seen = new Set(projects.map((project) => project.worktree))
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const project of projects) {
|
||||||
|
const root = map.get(project.worktree)
|
||||||
|
if (!root) continue
|
||||||
|
|
||||||
|
server.projects.close(project.worktree)
|
||||||
|
|
||||||
|
if (!seen.has(root)) {
|
||||||
|
server.projects.open(root)
|
||||||
|
seen.add(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.expanded) server.projects.expand(root)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||||
const list = createMemo(() => enriched().flatMap(colorize))
|
const list = createMemo(() => enriched().flatMap(colorize))
|
||||||
|
|
||||||
@@ -131,11 +160,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
projects: {
|
projects: {
|
||||||
list,
|
list,
|
||||||
open(directory: string) {
|
open(directory: string) {
|
||||||
if (server.projects.list().find((x) => x.worktree === directory)) {
|
const root = roots().get(directory) ?? directory
|
||||||
return
|
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||||
}
|
globalSync.project.loadSessions(root)
|
||||||
globalSync.project.loadSessions(directory)
|
server.projects.open(root)
|
||||||
server.projects.open(directory)
|
|
||||||
},
|
},
|
||||||
close(directory: string) {
|
close(directory: string) {
|
||||||
server.projects.close(directory)
|
server.projects.close(directory)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { batch, createMemo } from "solid-js"
|
import { batch, createMemo, onCleanup } from "solid-js"
|
||||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
@@ -465,7 +465,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const searchFilesAndDirectories = (query: string) =>
|
const searchFilesAndDirectories = (query: string) =>
|
||||||
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
|
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
|
||||||
|
|
||||||
sdk.event.listen((e) => {
|
const unsub = sdk.event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "file.watcher.updated":
|
case "file.watcher.updated":
|
||||||
@@ -475,6 +475,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
node: async (path: string) => {
|
node: async (path: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
@@ -54,7 +55,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
const base = {
|
const base = {
|
||||||
@@ -104,6 +105,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { onCleanup } from "solid-js"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
|
||||||
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
globalSDK.event.on(props.directory, async (event) => {
|
const unsub = globalSDK.event.on(props.directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
|
onCleanup(unsub)
|
||||||
|
|
||||||
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -644,13 +644,13 @@ export default function Layout(props: ParentProps) {
|
|||||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||||
|
const [sessionStore] = globalSync.child(props.session.directory)
|
||||||
const hasPermissions = createMemo(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
const store = globalSync.child(props.project.worktree)[0]
|
const permissions = sessionStore.permission?.[props.session.id] ?? []
|
||||||
const permissions = store.permission?.[props.session.id] ?? []
|
|
||||||
if (permissions.length > 0) return true
|
if (permissions.length > 0) return true
|
||||||
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
|
const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
|
||||||
for (const child of childSessions) {
|
for (const child of childSessions) {
|
||||||
const childPermissions = store.permission?.[child.id] ?? []
|
const childPermissions = sessionStore.permission?.[child.id] ?? []
|
||||||
if (childPermissions.length > 0) return true
|
if (childPermissions.length > 0) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -658,7 +658,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
const isWorking = createMemo(() => {
|
const isWorking = createMemo(() => {
|
||||||
if (props.session.id === params.id) return false
|
if (props.session.id === params.id) return false
|
||||||
if (hasPermissions()) return false
|
if (hasPermissions()) return false
|
||||||
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
|
const status = sessionStore.session_status[props.session.id]
|
||||||
return status?.type === "busy" || status?.type === "retry"
|
return status?.type === "busy" || status?.type === "retry"
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
@@ -759,6 +759,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
const isExpanded = createMemo(() =>
|
const isExpanded = createMemo(() =>
|
||||||
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
||||||
)
|
)
|
||||||
|
const isActive = createMemo(() => {
|
||||||
|
const current = params.dir ? base64Decode(params.dir) : ""
|
||||||
|
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
||||||
|
})
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (props.mobile) {
|
if (props.mobile) {
|
||||||
if (open) mobileProjects.expand(props.project.worktree)
|
if (open) mobileProjects.expand(props.project.worktree)
|
||||||
@@ -777,7 +781,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
<Button
|
<Button
|
||||||
as={"div"}
|
as={"div"}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
|
classList={{
|
||||||
|
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
|
||||||
|
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
||||||
<ProjectAvatar
|
<ProjectAvatar
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, batch } from "solid-js"
|
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
|
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
|
||||||
@@ -24,7 +25,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { Terminal } from "@/components/terminal"
|
import { Terminal } from "@/components/terminal"
|
||||||
import { checksum, base64Decode } from "@opencode-ai/util/encode"
|
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||||
@@ -48,12 +49,7 @@ import {
|
|||||||
NewSessionView,
|
NewSessionView,
|
||||||
} from "@/components/session"
|
} from "@/components/session"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
|
||||||
if (a === b) return true
|
|
||||||
if (a.length !== b.length) return false
|
|
||||||
return a.every((x, i) => x === b[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiffStyle = "unified" | "split"
|
type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
@@ -253,6 +249,14 @@ export default function Page() {
|
|||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
mobileTab: "session" as "session" | "review",
|
mobileTab: "session" as "session" | "review",
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
|
promptHeight: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSessionWorktree = createMemo(() => {
|
||||||
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
|
const project = sync.project
|
||||||
|
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
|
||||||
|
return "main"
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeMessage = createMemo(() => {
|
const activeMessage = createMemo(() => {
|
||||||
@@ -287,6 +291,8 @@ export default function Page() {
|
|||||||
|
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
|
let promptDock: HTMLDivElement | undefined
|
||||||
|
let scroller: HTMLDivElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
@@ -666,9 +672,30 @@ export default function Page() {
|
|||||||
const anchor = (id: string) => `message-${id}`
|
const anchor = (id: string) => `message-${id}`
|
||||||
|
|
||||||
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
const setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||||
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => promptDock,
|
||||||
|
({ height }) => {
|
||||||
|
const next = Math.ceil(height)
|
||||||
|
|
||||||
|
if (next === store.promptHeight) return
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
|
||||||
|
|
||||||
|
setStore("promptHeight", next)
|
||||||
|
|
||||||
|
if (stick && el) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const updateHash = (id: string) => {
|
const updateHash = (id: string) => {
|
||||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||||
}
|
}
|
||||||
@@ -774,7 +801,10 @@ export default function Page() {
|
|||||||
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
|
||||||
"flex-1 md:flex-none py-6 md:py-3": true,
|
"flex-1 md:flex-none py-6 md:py-3": true,
|
||||||
}}
|
}}
|
||||||
style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }}
|
style={{
|
||||||
|
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
|
||||||
|
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-h-0 overflow-hidden">
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -789,7 +819,7 @@ export default function Page() {
|
|||||||
view={view}
|
view={view}
|
||||||
diffStyle="unified"
|
diffStyle="unified"
|
||||||
classes={{
|
classes={{
|
||||||
root: "pb-32",
|
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
|
||||||
header: "px-4",
|
header: "px-4",
|
||||||
container: "px-4",
|
container: "px-4",
|
||||||
}}
|
}}
|
||||||
@@ -820,7 +850,7 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={autoScroll.contentRef}
|
ref={autoScroll.contentRef}
|
||||||
class="flex flex-col gap-32 items-start justify-start pb-32 md:pb-40 transition-[margin]"
|
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
|
||||||
classList={{
|
classList={{
|
||||||
"mt-0.5": !showTabs(),
|
"mt-0.5": !showTabs(),
|
||||||
"mt-0": showTabs(),
|
"mt-0": showTabs(),
|
||||||
@@ -833,9 +863,9 @@ export default function Page() {
|
|||||||
data-message-id={message.id}
|
data-message-id={message.id}
|
||||||
classList={{
|
classList={{
|
||||||
"min-w-0 w-full max-w-full": true,
|
"min-w-0 w-full max-w-full": true,
|
||||||
"last:min-h-[calc(100vh-13.5rem)] md:last:min-h-[calc(100vh-14.5rem)]":
|
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||||
platform.platform !== "desktop",
|
platform.platform !== "desktop",
|
||||||
"last:min-h-[calc(100vh-15rem)] md:last:min-h-[calc(100vh-16rem)]":
|
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||||
platform.platform === "desktop",
|
platform.platform === "desktop",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -871,15 +901,31 @@ export default function Page() {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<NewSessionView
|
<NewSessionView
|
||||||
worktree={store.newSessionWorktree}
|
worktree={newSessionWorktree()}
|
||||||
onWorktreeChange={(value) => setStore("newSessionWorktree", value)}
|
onWorktreeChange={(value) => {
|
||||||
|
if (value === "create") {
|
||||||
|
setStore("newSessionWorktree", value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("newSessionWorktree", "main")
|
||||||
|
|
||||||
|
const target = value === "main" ? sync.project?.worktree : value
|
||||||
|
if (!target) return
|
||||||
|
if (target === sync.data.path.directory) return
|
||||||
|
layout.projects.open(target)
|
||||||
|
navigate(`/${base64Encode(target)}/session`)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt input */}
|
{/* Prompt input */}
|
||||||
<div class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none">
|
<div
|
||||||
|
ref={(el) => (promptDock = el)}
|
||||||
|
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"w-full md:px-6 pointer-events-auto": true,
|
"w-full md:px-6 pointer-events-auto": true,
|
||||||
@@ -890,7 +936,7 @@ export default function Page() {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
inputRef = el
|
inputRef = el
|
||||||
}}
|
}}
|
||||||
newSessionWorktree={store.newSessionWorktree}
|
newSessionWorktree={newSessionWorktree()}
|
||||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,47 +1,185 @@
|
|||||||
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
|
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
|
||||||
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
|
|
||||||
|
type Inline =
|
||||||
|
| {
|
||||||
|
type: "file"
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
value: string
|
||||||
|
path: string
|
||||||
|
selection?: {
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
startChar: number
|
||||||
|
endChar: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "agent"
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
value: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
||||||
|
const queryIndex = url.indexOf("?")
|
||||||
|
if (queryIndex === -1) return undefined
|
||||||
|
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||||
|
const startLine = Number(params.get("start"))
|
||||||
|
const endLine = Number(params.get("end"))
|
||||||
|
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
endLine,
|
||||||
|
startChar: 0,
|
||||||
|
endChar: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textPartValue(parts: Part[]) {
|
||||||
|
const candidates = parts
|
||||||
|
.filter((part): part is TextPart => part.type === "text")
|
||||||
|
.filter((part) => !part.synthetic && !part.ignored)
|
||||||
|
return candidates.reduce((best: TextPart | undefined, part) => {
|
||||||
|
if (!best) return part
|
||||||
|
if (part.text.length > best.text.length) return part
|
||||||
|
return best
|
||||||
|
}, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract prompt content from message parts for restoring into the prompt input.
|
* Extract prompt content from message parts for restoring into the prompt input.
|
||||||
* This is used by undo to restore the original user prompt.
|
* This is used by undo to restore the original user prompt.
|
||||||
*/
|
*/
|
||||||
export function extractPromptFromParts(parts: Part[]): Prompt {
|
export function extractPromptFromParts(parts: Part[]): Prompt {
|
||||||
const result: Prompt = []
|
const textPart = textPartValue(parts)
|
||||||
let position = 0
|
const text = textPart?.text ?? ""
|
||||||
|
|
||||||
|
const inline: Inline[] = []
|
||||||
|
const images: ImageAttachmentPart[] = []
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === "text") {
|
if (part.type === "file") {
|
||||||
const textPart = part as TextPart
|
|
||||||
if (!textPart.synthetic && textPart.text) {
|
|
||||||
result.push({
|
|
||||||
type: "text",
|
|
||||||
content: textPart.text,
|
|
||||||
start: position,
|
|
||||||
end: position + textPart.text.length,
|
|
||||||
})
|
|
||||||
position += textPart.text.length
|
|
||||||
}
|
|
||||||
} else if (part.type === "file") {
|
|
||||||
const filePart = part as FilePart
|
const filePart = part as FilePart
|
||||||
if (filePart.source?.type === "file") {
|
const sourceText = filePart.source?.text
|
||||||
const path = filePart.source.path
|
if (sourceText) {
|
||||||
const content = "@" + path
|
const value = sourceText.value
|
||||||
const attachment: FileAttachmentPart = {
|
const start = sourceText.start
|
||||||
type: "file",
|
const end = sourceText.end
|
||||||
path,
|
let path = value
|
||||||
content,
|
if (value.startsWith("@")) path = value.slice(1)
|
||||||
start: position,
|
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
|
||||||
end: position + content.length,
|
path = filePart.source.path
|
||||||
}
|
}
|
||||||
result.push(attachment)
|
inline.push({
|
||||||
position += content.length
|
type: "file",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
selection: selectionFromFileUrl(filePart.url),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePart.url.startsWith("data:")) {
|
||||||
|
images.push({
|
||||||
|
type: "image",
|
||||||
|
id: filePart.id,
|
||||||
|
filename: filePart.filename ?? "attachment",
|
||||||
|
mime: filePart.mime,
|
||||||
|
dataUrl: filePart.url,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.type === "agent") {
|
||||||
|
const agentPart = part as MessageAgentPart
|
||||||
|
const source = agentPart.source
|
||||||
|
if (!source) continue
|
||||||
|
inline.push({
|
||||||
|
type: "agent",
|
||||||
|
start: source.start,
|
||||||
|
end: source.end,
|
||||||
|
value: source.value,
|
||||||
|
name: agentPart.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline.sort((a, b) => {
|
||||||
|
if (a.start !== b.start) return a.start - b.start
|
||||||
|
return a.end - b.end
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: Prompt = []
|
||||||
|
let position = 0
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
const pushText = (content: string) => {
|
||||||
|
if (!content) return
|
||||||
|
result.push({
|
||||||
|
type: "text",
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
})
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
|
||||||
|
const content = item.value
|
||||||
|
const attachment: FileAttachmentPart = {
|
||||||
|
type: "file",
|
||||||
|
path: item.path,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
selection: item.selection,
|
||||||
|
}
|
||||||
|
result.push(attachment)
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
|
||||||
|
const content = item.value
|
||||||
|
const mention: AgentPart = {
|
||||||
|
type: "agent",
|
||||||
|
name: item.name,
|
||||||
|
content,
|
||||||
|
start: position,
|
||||||
|
end: position + content.length,
|
||||||
|
}
|
||||||
|
result.push(mention)
|
||||||
|
position += content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of inline) {
|
||||||
|
if (item.start < 0 || item.end < item.start) continue
|
||||||
|
if (item.end > text.length) continue
|
||||||
|
if (item.start < cursor) continue
|
||||||
|
|
||||||
|
pushText(text.slice(cursor, item.start))
|
||||||
|
|
||||||
|
if (item.type === "file") {
|
||||||
|
pushFile(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "agent") {
|
||||||
|
pushAgent(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = item.end
|
||||||
|
}
|
||||||
|
|
||||||
|
pushText(text.slice(cursor))
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
result.push({ type: "text", content: "", start: 0, end: 0 })
|
result.push({ type: "text", content: "", start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
if (images.length === 0) return result
|
||||||
|
return [...result, ...images]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||||
|
if (a === b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
return a.every((x, i) => x === b[i])
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -172,8 +172,8 @@ export async function handler(
|
|||||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||||
await trialLimiter?.track(tokensInfo)
|
await trialLimiter?.track(tokensInfo)
|
||||||
await rateLimiter?.track()
|
await rateLimiter?.track()
|
||||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||||
await reload(authInfo)
|
await reload(authInfo, costInfo)
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: resStatus,
|
status: resStatus,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
@@ -206,8 +206,8 @@ export async function handler(
|
|||||||
if (usage) {
|
if (usage) {
|
||||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||||
await trialLimiter?.track(tokensInfo)
|
await trialLimiter?.track(tokensInfo)
|
||||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||||
await reload(authInfo)
|
await reload(authInfo, costInfo)
|
||||||
}
|
}
|
||||||
c.close()
|
c.close()
|
||||||
return
|
return
|
||||||
@@ -392,6 +392,7 @@ export async function handler(
|
|||||||
monthlyUsage: BillingTable.monthlyUsage,
|
monthlyUsage: BillingTable.monthlyUsage,
|
||||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||||
reloadTrigger: BillingTable.reloadTrigger,
|
reloadTrigger: BillingTable.reloadTrigger,
|
||||||
|
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id: UserTable.id,
|
id: UserTable.id,
|
||||||
@@ -560,61 +561,68 @@ export async function handler(
|
|||||||
if (!authInfo) return
|
if (!authInfo) return
|
||||||
|
|
||||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||||
await Database.transaction(async (tx) => {
|
await Database.use((db) =>
|
||||||
await tx.insert(UsageTable).values({
|
Promise.all([
|
||||||
workspaceID: authInfo.workspaceID,
|
db.insert(UsageTable).values({
|
||||||
id: Identifier.create("usage"),
|
workspaceID: authInfo.workspaceID,
|
||||||
model: modelInfo.id,
|
id: Identifier.create("usage"),
|
||||||
provider: providerInfo.id,
|
model: modelInfo.id,
|
||||||
inputTokens,
|
provider: providerInfo.id,
|
||||||
outputTokens,
|
inputTokens,
|
||||||
reasoningTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
reasoningTokens,
|
||||||
cacheWrite5mTokens,
|
cacheReadTokens,
|
||||||
cacheWrite1hTokens,
|
cacheWrite5mTokens,
|
||||||
cost,
|
cacheWrite1hTokens,
|
||||||
keyID: authInfo.apiKeyId,
|
cost,
|
||||||
})
|
keyID: authInfo.apiKeyId,
|
||||||
await tx
|
}),
|
||||||
.update(BillingTable)
|
db
|
||||||
.set({
|
.update(BillingTable)
|
||||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
.set({
|
||||||
monthlyUsage: sql`
|
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||||
|
monthlyUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeMonthlyUsageUpdated: sql`now()`,
|
timeMonthlyUsageUpdated: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
|
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||||
await tx
|
db
|
||||||
.update(UserTable)
|
.update(UserTable)
|
||||||
.set({
|
.set({
|
||||||
monthlyUsage: sql`
|
monthlyUsage: sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||||
ELSE ${cost}
|
ELSE ${cost}
|
||||||
END
|
END
|
||||||
`,
|
`,
|
||||||
timeMonthlyUsageUpdated: sql`now()`,
|
timeMonthlyUsageUpdated: sql`now()`,
|
||||||
})
|
})
|
||||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
|
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||||
})
|
db
|
||||||
|
.update(KeyTable)
|
||||||
await Database.use((tx) =>
|
.set({ timeUsed: sql`now()` })
|
||||||
tx
|
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||||
.update(KeyTable)
|
]),
|
||||||
.set({ timeUsed: sql`now()` })
|
|
||||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return { costInMicroCents: cost }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload(authInfo: AuthInfo) {
|
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
|
||||||
if (!authInfo) return
|
if (!authInfo) return
|
||||||
if (authInfo.isFree) return
|
if (authInfo.isFree) return
|
||||||
if (authInfo.provider?.credentials) return
|
if (authInfo.provider?.credentials) return
|
||||||
|
|
||||||
|
if (!costInfo) return
|
||||||
|
|
||||||
|
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
|
||||||
|
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
|
||||||
|
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
|
||||||
|
|
||||||
const lock = await Database.use((tx) =>
|
const lock = await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
.update(BillingTable)
|
.update(BillingTable)
|
||||||
@@ -625,10 +633,7 @@ export async function handler(
|
|||||||
and(
|
and(
|
||||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||||
eq(BillingTable.reload, true),
|
eq(BillingTable.reload, true),
|
||||||
lt(
|
lt(BillingTable.balance, reloadTrigger),
|
||||||
BillingTable.balance,
|
|
||||||
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
|
|
||||||
),
|
|
||||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
@@ -2777,6 +2777,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"listeners",
|
"listeners",
|
||||||
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ serde_json = "1"
|
|||||||
tokio = "1.48.0"
|
tokio = "1.48.0"
|
||||||
listeners = "0.3"
|
listeners = "0.3"
|
||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
|
semver = "1.0.27"
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
|
const CLI_BINARY_NAME: &str = "opencode";
|
||||||
|
|
||||||
|
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||||
|
std::env::var("HOME").ok().map(|home| {
|
||||||
|
std::path::PathBuf::from(home)
|
||||||
|
.join(CLI_INSTALL_DIR)
|
||||||
|
.join(CLI_BINARY_NAME)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sidecar_path() -> std::path::PathBuf {
|
||||||
|
tauri::utils::platform::current_exe()
|
||||||
|
.expect("Failed to get current exe")
|
||||||
|
.parent()
|
||||||
|
.expect("Failed to get parent dir")
|
||||||
|
.join("opencode-cli")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cli_installed() -> bool {
|
||||||
|
get_cli_install_path()
|
||||||
|
.map(|path| path.exists())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn install_cli() -> Result<String, String> {
|
||||||
|
if cfg!(not(unix)) {
|
||||||
|
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let sidecar = get_sidecar_path();
|
||||||
|
if !sidecar.exists() {
|
||||||
|
return Err("Sidecar binary not found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_script = std::env::temp_dir().join("opencode-install.sh");
|
||||||
|
std::fs::write(&temp_script, INSTALL_SCRIPT)
|
||||||
|
.map_err(|e| format!("Failed to write install script: {}", e))?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = std::process::Command::new(&temp_script)
|
||||||
|
.arg("--binary")
|
||||||
|
.arg(&sidecar)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run install script: {}", e))?;
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&temp_script);
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!("Install script failed: {}", stderr));
|
||||||
|
}
|
||||||
|
|
||||||
|
let install_path =
|
||||||
|
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
|
||||||
|
|
||||||
|
Ok(install_path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
println!("Skipping CLI sync for debug build");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !is_cli_installed() {
|
||||||
|
println!("No CLI installation found, skipping sync");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_path =
|
||||||
|
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
|
||||||
|
|
||||||
|
let output = std::process::Command::new(&cli_path)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err("Failed to get CLI version".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let cli_version = semver::Version::parse(&cli_version_str)
|
||||||
|
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
|
||||||
|
|
||||||
|
let app_version = app.package_info().version.clone();
|
||||||
|
|
||||||
|
if cli_version >= app_version {
|
||||||
|
println!(
|
||||||
|
"CLI version {} is up to date (app version: {}), skipping sync",
|
||||||
|
cli_version, app_version
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"CLI version {} is older than app version {}, syncing",
|
||||||
|
cli_version, app_version
|
||||||
|
);
|
||||||
|
|
||||||
|
install_cli()?;
|
||||||
|
|
||||||
|
println!("Synced installed CLI");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
|
mod cli;
|
||||||
mod window_customizer;
|
mod window_customizer;
|
||||||
|
|
||||||
|
use cli::{get_sidecar_path, install_cli, sync_cli};
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
net::{SocketAddr, TcpListener},
|
net::{SocketAddr, TcpListener},
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
|
use tauri::{
|
||||||
|
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
|
||||||
|
};
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
@@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(target_os = "windows"))]
|
||||||
let (mut rx, child) = {
|
let (mut rx, child) = {
|
||||||
let sidecar_path = tauri::utils::platform::current_exe()
|
let sidecar = get_sidecar_path();
|
||||||
.expect("Failed to get current exe")
|
|
||||||
.parent()
|
|
||||||
.expect("Failed to get parent dir")
|
|
||||||
.join("opencode-cli");
|
|
||||||
let shell = get_user_shell();
|
let shell = get_user_shell();
|
||||||
app.shell()
|
app.shell()
|
||||||
.command(&shell)
|
.command(&shell)
|
||||||
@@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
.args([
|
.args([
|
||||||
"-il",
|
"-il",
|
||||||
"-c",
|
"-c",
|
||||||
&format!("{} serve --port={}", sidecar_path.display(), port),
|
&format!("{} serve --port={}", sidecar.display(), port),
|
||||||
])
|
])
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to spawn opencode")
|
.expect("Failed to spawn opencode")
|
||||||
@@ -203,7 +203,8 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
kill_sidecar,
|
kill_sidecar,
|
||||||
copy_logs_to_clipboard,
|
copy_logs_to_clipboard,
|
||||||
get_logs
|
get_logs,
|
||||||
|
install_cli
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app = app.handle().clone();
|
let app = app.handle().clone();
|
||||||
@@ -211,83 +212,95 @@ pub fn run() {
|
|||||||
// Initialize log state
|
// Initialize log state
|
||||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
{
|
||||||
let port = get_sidecar_port();
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let port = get_sidecar_port();
|
||||||
|
|
||||||
let should_spawn_sidecar = !is_server_running(port).await;
|
let should_spawn_sidecar = !is_server_running(port).await;
|
||||||
|
|
||||||
let child = if should_spawn_sidecar {
|
let child = if should_spawn_sidecar {
|
||||||
let child = spawn_sidecar(&app, port);
|
let child = spawn_sidecar(&app, port);
|
||||||
|
|
||||||
let timestamp = Instant::now();
|
let timestamp = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||||
let res = app.dialog()
|
let res = app.dialog()
|
||||||
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
||||||
.title("Startup Failed")
|
.title("Startup Failed")
|
||||||
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
||||||
.blocking_show_with_result();
|
.blocking_show_with_result();
|
||||||
|
|
||||||
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
||||||
match copy_logs_to_clipboard(app.clone()).await {
|
match copy_logs_to_clipboard(app.clone()).await {
|
||||||
Ok(()) => println!("Logs copied to clipboard successfully"),
|
Ok(()) => println!("Logs copied to clipboard successfully"),
|
||||||
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.exit(1);
|
app.exit(1);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
if is_server_running(port).await {
|
if is_server_running(port).await {
|
||||||
// give the server a little bit more time to warm up
|
// give the server a little bit more time to warm up
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Server ready after {:?}", timestamp.elapsed());
|
println!("Server ready after {:?}", timestamp.elapsed());
|
||||||
|
|
||||||
Some(child)
|
Some(child)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||||
let size = primary_monitor
|
let size = primary_monitor
|
||||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||||
|
|
||||||
let mut window_builder =
|
let mut window_builder =
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||||
.title("OpenCode")
|
.title("OpenCode")
|
||||||
.inner_size(size.width as f64, size.height as f64)
|
.inner_size(size.width as f64, size.height as f64)
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
.zoom_hotkeys_enabled(true)
|
.zoom_hotkeys_enabled(true)
|
||||||
.disable_drag_drop_handler()
|
.disable_drag_drop_handler()
|
||||||
.initialization_script(format!(
|
.initialization_script(format!(
|
||||||
r#"
|
r#"
|
||||||
window.__OPENCODE__ ??= {{}};
|
window.__OPENCODE__ ??= {{}};
|
||||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||||
window.__OPENCODE__.port = {port};
|
window.__OPENCODE__.port = {port};
|
||||||
"#
|
"#
|
||||||
));
|
));
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
window_builder = window_builder
|
window_builder = window_builder
|
||||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||||
.hidden_title(true);
|
.hidden_title(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window_builder.build().expect("Failed to create window");
|
||||||
|
|
||||||
|
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let app = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = sync_cli(app) {
|
||||||
|
eprintln!("Failed to sync CLI: {e}");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
window_builder.build().expect("Failed to create window");
|
}
|
||||||
|
|
||||||
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
|
import { message } from "@tauri-apps/plugin-dialog"
|
||||||
|
|
||||||
|
export async function installCli(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const path = await invoke<string>("install_cli")
|
||||||
|
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
|
||||||
|
title: "CLI Installed",
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men
|
|||||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||||
|
|
||||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||||
|
import { installCli } from "./cli"
|
||||||
|
|
||||||
export async function createMenu() {
|
export async function createMenu() {
|
||||||
if (ostype() !== "macos") return
|
if (ostype() !== "macos") return
|
||||||
@@ -19,6 +20,10 @@ export async function createMenu() {
|
|||||||
action: () => runUpdater({ alertOnFail: true }),
|
action: () => runUpdater({ alertOnFail: true }),
|
||||||
text: "Check For Updates...",
|
text: "Check For Updates...",
|
||||||
}),
|
}),
|
||||||
|
await MenuItem.new({
|
||||||
|
action: () => installCli(),
|
||||||
|
text: "Install CLI...",
|
||||||
|
}),
|
||||||
await PredefinedMenuItem.new({
|
await PredefinedMenuItem.new({
|
||||||
item: "Separator",
|
item: "Separator",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.1.2"
|
version = "1.1.3"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -335,7 +335,11 @@ export const AuthLoginCommand = cmd({
|
|||||||
|
|
||||||
if (provider === "amazon-bedrock") {
|
if (provider === "amazon-bedrock") {
|
||||||
prompts.log.info(
|
prompts.log.info(
|
||||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
"Amazon Bedrock authentication priority:\n" +
|
||||||
|
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||||
|
" 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
|
||||||
|
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||||
|
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||||
)
|
)
|
||||||
prompts.outro("Done")
|
prompts.outro("Done")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ jobs:
|
|||||||
issues: read
|
issues: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@latest${envStr}
|
uses: anomalyco/opencode/github@latest${envStr}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
|||||||
|
|
||||||
export const McpCommand = cmd({
|
export const McpCommand = cmd({
|
||||||
command: "mcp",
|
command: "mcp",
|
||||||
|
describe: "manage MCP (Model Context Protocol) servers",
|
||||||
builder: (yargs) =>
|
builder: (yargs) =>
|
||||||
yargs
|
yargs
|
||||||
.command(McpAddCommand)
|
.command(McpAddCommand)
|
||||||
|
|||||||
@@ -569,6 +569,7 @@ function App() {
|
|||||||
|
|
||||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||||
const error = evt.properties.error
|
const error = evt.properties.error
|
||||||
|
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||||
const message = (() => {
|
const message = (() => {
|
||||||
if (!error) return "An error occurred"
|
if (!error) return "An error occurred"
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export type AutocompleteOption = {
|
|||||||
aliases?: string[]
|
aliases?: string[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
description?: string
|
description?: string
|
||||||
|
isDirectory?: boolean
|
||||||
onSelect?: () => void
|
onSelect?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +201,10 @@ export function Autocomplete(props: {
|
|||||||
url = urlObj.toString()
|
url = urlObj.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDir = item.endsWith("/")
|
||||||
return {
|
return {
|
||||||
display: Locale.truncateMiddle(filename, width),
|
display: Locale.truncateMiddle(filename, width),
|
||||||
|
isDirectory: isDir,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
insertPart(filename, {
|
insertPart(filename, {
|
||||||
type: "file",
|
type: "file",
|
||||||
@@ -511,6 +514,27 @@ export function Autocomplete(props: {
|
|||||||
selected.onSelect?.()
|
selected.onSelect?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandDirectory() {
|
||||||
|
const selected = options()[store.selected]
|
||||||
|
if (!selected) return
|
||||||
|
|
||||||
|
const input = props.input()
|
||||||
|
const currentCursorOffset = input.cursorOffset
|
||||||
|
|
||||||
|
const displayText = selected.display.trimEnd()
|
||||||
|
const path = displayText.startsWith("@") ? displayText.slice(1) : displayText
|
||||||
|
|
||||||
|
input.cursorOffset = store.index
|
||||||
|
const startCursor = input.logicalCursor
|
||||||
|
input.cursorOffset = currentCursorOffset
|
||||||
|
const endCursor = input.logicalCursor
|
||||||
|
|
||||||
|
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||||
|
input.insertText("@" + path)
|
||||||
|
|
||||||
|
setStore("selected", 0)
|
||||||
|
}
|
||||||
|
|
||||||
function show(mode: "@" | "/") {
|
function show(mode: "@" | "/") {
|
||||||
command.keybinds(false)
|
command.keybinds(false)
|
||||||
setStore({
|
setStore({
|
||||||
@@ -575,11 +599,21 @@ export function Autocomplete(props: {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name === "return" || name === "tab") {
|
if (name === "return") {
|
||||||
select()
|
select()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (name === "tab") {
|
||||||
|
const selected = options()[store.selected]
|
||||||
|
if (selected?.isDirectory) {
|
||||||
|
expandDirectory()
|
||||||
|
} else {
|
||||||
|
select()
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!store.visible) {
|
if (!store.visible) {
|
||||||
if (e.name === "@") {
|
if (e.name === "@") {
|
||||||
|
|||||||
@@ -559,6 +559,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||||
messageID,
|
messageID,
|
||||||
variant,
|
variant,
|
||||||
|
parts: nonTextParts
|
||||||
|
.filter((x) => x.type === "file")
|
||||||
|
.map((x) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
...x,
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
sdk.client.session.prompt({
|
sdk.client.session.prompt({
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
type ThemeColors = {
|
type ThemeColors = {
|
||||||
primary: RGBA
|
primary: RGBA
|
||||||
@@ -310,32 +311,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
function resolveSystemTheme() {
|
||||||
renderer
|
console.log("resolved system theme")
|
||||||
.getPalette({
|
renderer
|
||||||
size: 16,
|
.getPalette({
|
||||||
})
|
size: 16,
|
||||||
.then((colors) => {
|
})
|
||||||
if (!colors.palette[0]) {
|
.then((colors) => {
|
||||||
if (store.active === "system") {
|
if (!colors.palette[0]) {
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.active = "opencode"
|
|
||||||
draft.ready = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.themes.system = generateSystem(colors, store.mode)
|
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
draft.ready = true
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.active = "opencode"
|
||||||
|
draft.ready = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}),
|
return
|
||||||
)
|
}
|
||||||
})
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.themes.system = generateSystem(colors, store.mode)
|
||||||
|
if (store.active === "system") {
|
||||||
|
draft.ready = true
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = useRenderer()
|
||||||
|
resolveSystemTheme()
|
||||||
|
|
||||||
|
const sdk = useSDK()
|
||||||
|
sdk.event.on("server.instance.disposed", () => {
|
||||||
|
resolveSystemTheme()
|
||||||
|
})
|
||||||
|
|
||||||
const values = createMemo(() => {
|
const values = createMemo(() => {
|
||||||
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"darkBlue": "#6ba1e6",
|
"darkBlue": "#6ba1e6",
|
||||||
"darkCyan": "#56b6c2",
|
"darkCyan": "#56b6c2",
|
||||||
"darkYellow": "#e5c07b",
|
"darkYellow": "#e5c07b",
|
||||||
|
"darkPanelBg": "#2a1a1599",
|
||||||
"lightStep6": "#d4d4d4",
|
"lightStep6": "#d4d4d4",
|
||||||
"lightStep11": "#8a8a8a",
|
"lightStep11": "#8a8a8a",
|
||||||
"lightStep12": "#1a1a1a",
|
"lightStep12": "#1a1a1a",
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"lightOrange": "#EC5B2B",
|
"lightOrange": "#EC5B2B",
|
||||||
"lightBlue": "#0062d1",
|
"lightBlue": "#0062d1",
|
||||||
"lightCyan": "#318795",
|
"lightCyan": "#318795",
|
||||||
"lightYellow": "#b0851f"
|
"lightYellow": "#b0851f",
|
||||||
|
"lightPanelBg": "#fff5f099"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"primary": {
|
"primary": {
|
||||||
@@ -71,6 +73,10 @@
|
|||||||
"dark": "transparent",
|
"dark": "transparent",
|
||||||
"light": "transparent"
|
"light": "transparent"
|
||||||
},
|
},
|
||||||
|
"backgroundMenu": {
|
||||||
|
"dark": "darkPanelBg",
|
||||||
|
"light": "lightPanelBg"
|
||||||
|
},
|
||||||
"border": {
|
"border": {
|
||||||
"dark": "darkOrange",
|
"dark": "darkOrange",
|
||||||
"light": "lightOrange"
|
"light": "lightOrange"
|
||||||
|
|||||||
@@ -1204,7 +1204,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
<Show when={props.message.error}>
|
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
|
||||||
<box
|
<box
|
||||||
border={["left"]}
|
border={["left"]}
|
||||||
paddingTop={1}
|
paddingTop={1}
|
||||||
@@ -1219,15 +1219,27 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
|||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.last || final()}>
|
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<text marginTop={1}>
|
<text marginTop={1}>
|
||||||
<span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>{" "}
|
<span
|
||||||
|
style={{
|
||||||
|
fg:
|
||||||
|
props.message.error?.name === "MessageAbortedError"
|
||||||
|
? theme.textMuted
|
||||||
|
: local.agent.color(props.message.agent),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▣{" "}
|
||||||
|
</span>{" "}
|
||||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||||
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||||
<Show when={duration()}>
|
<Show when={duration()}>
|
||||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={props.message.error?.name === "MessageAbortedError"}>
|
||||||
|
<span style={{ fg: theme.textMuted }}> · interrupted</span>
|
||||||
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -395,27 +395,52 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
export type PermissionRule = z.infer<typeof PermissionRule>
|
export type PermissionRule = z.infer<typeof PermissionRule>
|
||||||
|
|
||||||
|
// Capture original key order before zod reorders, then rebuild in original order
|
||||||
|
const permissionPreprocess = (val: unknown) => {
|
||||||
|
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
||||||
|
return { __originalKeys: Object.keys(val), ...val }
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionTransform = (x: unknown): Record<string, PermissionRule> => {
|
||||||
|
if (typeof x === "string") return { "*": x as PermissionAction }
|
||||||
|
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
|
||||||
|
const { __originalKeys, ...rest } = obj
|
||||||
|
if (!__originalKeys) return rest as Record<string, PermissionRule>
|
||||||
|
const result: Record<string, PermissionRule> = {}
|
||||||
|
for (const key of __originalKeys) {
|
||||||
|
if (key in rest) result[key] = rest[key] as PermissionRule
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export const Permission = z
|
export const Permission = z
|
||||||
.object({
|
.preprocess(
|
||||||
read: PermissionRule.optional(),
|
permissionPreprocess,
|
||||||
edit: PermissionRule.optional(),
|
z
|
||||||
glob: PermissionRule.optional(),
|
.object({
|
||||||
grep: PermissionRule.optional(),
|
__originalKeys: z.string().array().optional(),
|
||||||
list: PermissionRule.optional(),
|
read: PermissionRule.optional(),
|
||||||
bash: PermissionRule.optional(),
|
edit: PermissionRule.optional(),
|
||||||
task: PermissionRule.optional(),
|
glob: PermissionRule.optional(),
|
||||||
external_directory: PermissionRule.optional(),
|
grep: PermissionRule.optional(),
|
||||||
todowrite: PermissionAction.optional(),
|
list: PermissionRule.optional(),
|
||||||
todoread: PermissionAction.optional(),
|
bash: PermissionRule.optional(),
|
||||||
webfetch: PermissionAction.optional(),
|
task: PermissionRule.optional(),
|
||||||
websearch: PermissionAction.optional(),
|
external_directory: PermissionRule.optional(),
|
||||||
codesearch: PermissionAction.optional(),
|
todowrite: PermissionAction.optional(),
|
||||||
lsp: PermissionRule.optional(),
|
todoread: PermissionAction.optional(),
|
||||||
doom_loop: PermissionAction.optional(),
|
webfetch: PermissionAction.optional(),
|
||||||
})
|
websearch: PermissionAction.optional(),
|
||||||
.catchall(PermissionRule)
|
codesearch: PermissionAction.optional(),
|
||||||
.or(PermissionAction)
|
lsp: PermissionRule.optional(),
|
||||||
.transform((x) => (typeof x === "string" ? { "*": x } : x))
|
doom_loop: PermissionAction.optional(),
|
||||||
|
})
|
||||||
|
.catchall(PermissionRule)
|
||||||
|
.or(PermissionAction),
|
||||||
|
)
|
||||||
|
.transform(permissionTransform)
|
||||||
.meta({
|
.meta({
|
||||||
ref: "PermissionConfig",
|
ref: "PermissionConfig",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Flag } from "../flag/flag"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
|
|
||||||
// Direct imports for bundled providers
|
// Direct imports for bundled providers
|
||||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||||
import { createAzure } from "@ai-sdk/azure"
|
import { createAzure } from "@ai-sdk/azure"
|
||||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||||
@@ -168,10 +168,22 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"amazon-bedrock": async () => {
|
"amazon-bedrock": async () => {
|
||||||
|
const config = await Config.get()
|
||||||
|
const providerConfig = config.provider?.["amazon-bedrock"]
|
||||||
|
|
||||||
const auth = await Auth.get("amazon-bedrock")
|
const auth = await Auth.get("amazon-bedrock")
|
||||||
const awsProfile = Env.get("AWS_PROFILE")
|
|
||||||
|
// Region precedence: 1) config file, 2) env var, 3) default
|
||||||
|
const configRegion = providerConfig?.options?.region
|
||||||
|
const envRegion = Env.get("AWS_REGION")
|
||||||
|
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
||||||
|
|
||||||
|
// Profile: config file takes precedence over env var
|
||||||
|
const configProfile = providerConfig?.options?.profile
|
||||||
|
const envProfile = Env.get("AWS_PROFILE")
|
||||||
|
const profile = configProfile ?? envProfile
|
||||||
|
|
||||||
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
||||||
const awsRegion = Env.get("AWS_REGION")
|
|
||||||
|
|
||||||
const awsBearerToken = iife(() => {
|
const awsBearerToken = iife(() => {
|
||||||
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
|
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
|
||||||
@@ -183,20 +195,30 @@ export namespace Provider {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
||||||
|
|
||||||
const defaultRegion = awsRegion ?? "us-east-1"
|
|
||||||
|
|
||||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||||
|
|
||||||
|
// Build credential provider options (only pass profile if specified)
|
||||||
|
const credentialProviderOptions = profile ? { profile } : {}
|
||||||
|
|
||||||
|
const providerOptions: AmazonBedrockProviderSettings = {
|
||||||
|
region: defaultRegion,
|
||||||
|
credentialProvider: fromNodeProviderChain(credentialProviderOptions),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
|
||||||
|
const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
|
||||||
|
if (endpoint) {
|
||||||
|
providerOptions.baseURL = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoload: true,
|
autoload: true,
|
||||||
options: {
|
options: providerOptions,
|
||||||
region: defaultRegion,
|
|
||||||
credentialProvider: fromNodeProviderChain(),
|
|
||||||
},
|
|
||||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||||
// Skip region prefixing if model already has global prefix
|
// Skip region prefixing if model already has a cross-region inference profile prefix
|
||||||
if (modelID.startsWith("global.")) {
|
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
|
||||||
return sdk.languageModel(modelID)
|
return sdk.languageModel(modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ function mimeToModality(mime: string): Modality | undefined {
|
|||||||
|
|
||||||
export namespace ProviderTransform {
|
export namespace ProviderTransform {
|
||||||
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
|
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
|
||||||
|
// 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") {
|
||||||
|
msgs = msgs
|
||||||
|
.map((msg) => {
|
||||||
|
if (typeof msg.content === "string") {
|
||||||
|
if (msg.content === "") return undefined
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
if (!Array.isArray(msg.content)) return msg
|
||||||
|
const filtered = msg.content.filter((part) => {
|
||||||
|
if (part.type === "text" || part.type === "reasoning") {
|
||||||
|
return part.text !== ""
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (filtered.length === 0) return undefined
|
||||||
|
return { ...msg, content: filtered }
|
||||||
|
})
|
||||||
|
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
|
||||||
|
}
|
||||||
|
|
||||||
if (model.api.id.includes("claude")) {
|
if (model.api.id.includes("claude")) {
|
||||||
return msgs.map((msg) => {
|
return msgs.map((msg) => {
|
||||||
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
|
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { Shell } from "@/shell/shell"
|
|||||||
export namespace Pty {
|
export namespace Pty {
|
||||||
const log = Log.create({ service: "pty" })
|
const log = Log.create({ service: "pty" })
|
||||||
|
|
||||||
|
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||||
|
const BUFFER_CHUNK = 64 * 1024
|
||||||
|
|
||||||
const pty = lazy(async () => {
|
const pty = lazy(async () => {
|
||||||
const { spawn } = await import("bun-pty")
|
const { spawn } = await import("bun-pty")
|
||||||
return spawn
|
return spawn
|
||||||
@@ -126,15 +129,19 @@ export namespace Pty {
|
|||||||
}
|
}
|
||||||
state().set(id, session)
|
state().set(id, session)
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (session.subscribers.size === 0) {
|
let open = false
|
||||||
session.buffer += data
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const ws of session.subscribers) {
|
for (const ws of session.subscribers) {
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState !== 1) {
|
||||||
ws.send(data)
|
session.subscribers.delete(ws)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
open = true
|
||||||
|
ws.send(data)
|
||||||
}
|
}
|
||||||
|
if (open) return
|
||||||
|
session.buffer += data
|
||||||
|
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||||
|
session.buffer = session.buffer.slice(-BUFFER_LIMIT)
|
||||||
})
|
})
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
log.info("session exited", { id, exitCode })
|
log.info("session exited", { id, exitCode })
|
||||||
@@ -196,8 +203,18 @@ export namespace Pty {
|
|||||||
log.info("client connected to session", { id })
|
log.info("client connected to session", { id })
|
||||||
session.subscribers.add(ws)
|
session.subscribers.add(ws)
|
||||||
if (session.buffer) {
|
if (session.buffer) {
|
||||||
ws.send(session.buffer)
|
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
|
||||||
session.buffer = ""
|
session.buffer = ""
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
|
||||||
|
ws.send(buffer.slice(i, i + BUFFER_CHUNK))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
session.subscribers.delete(ws)
|
||||||
|
session.buffer = buffer
|
||||||
|
ws.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
onMessage: (message: string | ArrayBuffer) => {
|
onMessage: (message: string | ArrayBuffer) => {
|
||||||
|
|||||||
@@ -1422,10 +1422,23 @@ export namespace SessionPrompt {
|
|||||||
arguments: z.string(),
|
arguments: z.string(),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
variant: z.string().optional(),
|
variant: z.string().optional(),
|
||||||
|
parts: z
|
||||||
|
.array(
|
||||||
|
z.discriminatedUnion("type", [
|
||||||
|
MessageV2.FilePart.omit({
|
||||||
|
messageID: true,
|
||||||
|
sessionID: true,
|
||||||
|
}).partial({
|
||||||
|
id: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
export type CommandInput = z.infer<typeof CommandInput>
|
export type CommandInput = z.infer<typeof CommandInput>
|
||||||
const bashRegex = /!`([^`]+)`/g
|
const bashRegex = /!`([^`]+)`/g
|
||||||
const argsRegex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g
|
// Match [Image N] as single token, quoted strings, or non-space sequences
|
||||||
|
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||||
const placeholderRegex = /\$(\d+)/g
|
const placeholderRegex = /\$(\d+)/g
|
||||||
const quoteTrimRegex = /^["']|["']$/g
|
const quoteTrimRegex = /^["']|["']$/g
|
||||||
/**
|
/**
|
||||||
@@ -1516,6 +1529,7 @@ export namespace SessionPrompt {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateParts = await resolvePromptParts(template)
|
||||||
const parts =
|
const parts =
|
||||||
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
|
||||||
? [
|
? [
|
||||||
@@ -1525,10 +1539,10 @@ export namespace SessionPrompt {
|
|||||||
description: command.description ?? "",
|
description: command.description ?? "",
|
||||||
command: input.command,
|
command: input.command,
|
||||||
// TODO: how can we make task tool accept a more complex input?
|
// TODO: how can we make task tool accept a more complex input?
|
||||||
prompt: await resolvePromptParts(template).then((x) => x.find((y) => y.type === "text")?.text ?? ""),
|
prompt: templateParts.find((y) => y.type === "text")?.text ?? "",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: await resolvePromptParts(template)
|
: [...templateParts, ...(input.parts ?? [])]
|
||||||
|
|
||||||
const result = (await prompt({
|
const result = (await prompt({
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
|||||||
@@ -868,3 +868,48 @@ test("merges legacy tools with existing permission config", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("permission config preserves key order", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
permission: {
|
||||||
|
"*": "deny",
|
||||||
|
edit: "ask",
|
||||||
|
write: "ask",
|
||||||
|
external_directory: "ask",
|
||||||
|
read: "allow",
|
||||||
|
todowrite: "allow",
|
||||||
|
todoread: "allow",
|
||||||
|
"thoughts_*": "allow",
|
||||||
|
"reasoning_model_*": "allow",
|
||||||
|
"tools_*": "allow",
|
||||||
|
"pr_comments_*": "allow",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const config = await Config.get()
|
||||||
|
expect(Object.keys(config.permission!)).toEqual([
|
||||||
|
"*",
|
||||||
|
"edit",
|
||||||
|
"write",
|
||||||
|
"external_directory",
|
||||||
|
"read",
|
||||||
|
"todowrite",
|
||||||
|
"todoread",
|
||||||
|
"thoughts_*",
|
||||||
|
"reasoning_model_*",
|
||||||
|
"tools_*",
|
||||||
|
"pr_comments_*",
|
||||||
|
])
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
|
|||||||
delete process.env["AZURE_OPENAI_API_KEY"]
|
delete process.env["AZURE_OPENAI_API_KEY"]
|
||||||
delete process.env["AWS_ACCESS_KEY_ID"]
|
delete process.env["AWS_ACCESS_KEY_ID"]
|
||||||
delete process.env["AWS_PROFILE"]
|
delete process.env["AWS_PROFILE"]
|
||||||
|
delete process.env["AWS_REGION"]
|
||||||
|
delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
|
||||||
delete process.env["OPENROUTER_API_KEY"]
|
delete process.env["OPENROUTER_API_KEY"]
|
||||||
delete process.env["GROQ_API_KEY"]
|
delete process.env["GROQ_API_KEY"]
|
||||||
delete process.env["MISTRAL_API_KEY"]
|
delete process.env["MISTRAL_API_KEY"]
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
import { test, expect } from "bun:test"
|
import { test, expect, mock } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
|
||||||
import { Instance } from "../../src/project/instance"
|
// === Mocks ===
|
||||||
import { Provider } from "../../src/provider/provider"
|
// These mocks are required because Provider.list() triggers:
|
||||||
import { Env } from "../../src/env"
|
// 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
|
||||||
import { Auth } from "../../src/auth"
|
// 2. Plugin.list() which calls BunProc.install() for default plugins
|
||||||
import { Global } from "../../src/global"
|
// Without mocks, these would attempt real package installations that timeout in tests.
|
||||||
|
|
||||||
|
mock.module("../../src/bun/index", () => ({
|
||||||
|
BunProc: {
|
||||||
|
install: async (pkg: string) => pkg,
|
||||||
|
run: async () => {
|
||||||
|
throw new Error("BunProc.run should not be called in tests")
|
||||||
|
},
|
||||||
|
which: () => process.execPath,
|
||||||
|
InstallFailedError: class extends Error {},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@aws-sdk/credential-providers", () => ({
|
||||||
|
fromNodeProviderChain: () => async () => ({
|
||||||
|
accessKeyId: "mock-access-key-id",
|
||||||
|
secretAccessKey: "mock-secret-access-key",
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockPlugin = () => ({})
|
||||||
|
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
|
||||||
|
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
|
||||||
|
|
||||||
|
// Import after mocks are set up
|
||||||
|
const { tmpdir } = await import("../fixture/fixture")
|
||||||
|
const { Instance } = await import("../../src/project/instance")
|
||||||
|
const { Provider } = await import("../../src/provider/provider")
|
||||||
|
const { Env } = await import("../../src/env")
|
||||||
|
const { Global } = await import("../../src/global")
|
||||||
|
|
||||||
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -34,13 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
|
|||||||
fn: async () => {
|
fn: async () => {
|
||||||
const providers = await Provider.list()
|
const providers = await Provider.list()
|
||||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||||
// Region from config should be used (not env var)
|
|
||||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
|
test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
@@ -65,129 +93,6 @@ test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
init: async () => {
|
|
||||||
Env.set("AWS_PROFILE", "default")
|
|
||||||
// AWS_REGION might be set in the environment, use that or default
|
|
||||||
},
|
|
||||||
fn: async () => {
|
|
||||||
const providers = await Provider.list()
|
|
||||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
|
||||||
// Should have some region set (either from env or default)
|
|
||||||
expect(providers["amazon-bedrock"].options?.region).toBeDefined()
|
|
||||||
expect(typeof providers["amazon-bedrock"].options?.region).toBe("string")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Bedrock: uses config region in provider options", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
provider: {
|
|
||||||
"amazon-bedrock": {
|
|
||||||
options: {
|
|
||||||
region: "eu-north-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
init: async () => {
|
|
||||||
Env.set("AWS_PROFILE", "default")
|
|
||||||
},
|
|
||||||
fn: async () => {
|
|
||||||
const providers = await Provider.list()
|
|
||||||
const bedrockProvider = providers["amazon-bedrock"]
|
|
||||||
expect(bedrockProvider).toBeDefined()
|
|
||||||
expect(bedrockProvider.options?.region).toBe("eu-north-1")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Bedrock: respects config region for different instances", async () => {
|
|
||||||
// First instance with EU config
|
|
||||||
await using tmp1 = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
provider: {
|
|
||||||
"amazon-bedrock": {
|
|
||||||
options: {
|
|
||||||
region: "eu-west-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp1.path,
|
|
||||||
init: async () => {
|
|
||||||
Env.set("AWS_PROFILE", "default")
|
|
||||||
Env.set("AWS_REGION", "us-east-1")
|
|
||||||
},
|
|
||||||
fn: async () => {
|
|
||||||
const providers1 = await Provider.list()
|
|
||||||
expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Second instance with US config
|
|
||||||
await using tmp2 = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
provider: {
|
|
||||||
"amazon-bedrock": {
|
|
||||||
options: {
|
|
||||||
region: "us-west-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp2.path,
|
|
||||||
init: async () => {
|
|
||||||
Env.set("AWS_PROFILE", "default")
|
|
||||||
Env.set("AWS_REGION", "eu-west-1")
|
|
||||||
},
|
|
||||||
fn: async () => {
|
|
||||||
const providers2 = await Provider.list()
|
|
||||||
expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
@@ -207,7 +112,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Setup auth.json with bearer token for amazon-bedrock
|
|
||||||
const authPath = path.join(Global.Path.data, "auth.json")
|
const authPath = path.join(Global.Path.data, "auth.json")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
authPath,
|
authPath,
|
||||||
@@ -222,7 +126,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
init: async () => {
|
init: async () => {
|
||||||
// Clear env vars so only auth.json should trigger autoload
|
|
||||||
Env.set("AWS_PROFILE", "")
|
Env.set("AWS_PROFILE", "")
|
||||||
Env.set("AWS_ACCESS_KEY_ID", "")
|
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||||
@@ -234,3 +137,69 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
provider: {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
options: {
|
||||||
|
profile: "my-custom-profile",
|
||||||
|
region: "us-east-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("AWS_PROFILE", "default")
|
||||||
|
Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||||
|
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Bedrock: includes custom endpoint in options when specified", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
provider: {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
options: {
|
||||||
|
endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
init: async () => {
|
||||||
|
Env.set("AWS_PROFILE", "default")
|
||||||
|
},
|
||||||
|
fn: async () => {
|
||||||
|
const providers = await Provider.list()
|
||||||
|
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||||
|
expect(providers["amazon-bedrock"].options?.endpoint).toBe(
|
||||||
|
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -410,6 +410,181 @@ describe("ProviderTransform.message - empty image handling", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("ProviderTransform.message - anthropic empty content filtering", () => {
|
||||||
|
const anthropicModel = {
|
||||||
|
id: "anthropic/claude-3-5-sonnet",
|
||||||
|
providerID: "anthropic",
|
||||||
|
api: {
|
||||||
|
id: "claude-3-5-sonnet-20241022",
|
||||||
|
url: "https://api.anthropic.com",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
name: "Claude 3.5 Sonnet",
|
||||||
|
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.003,
|
||||||
|
output: 0.015,
|
||||||
|
cache: { read: 0.0003, write: 0.00375 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 200000,
|
||||||
|
output: 8192,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
} as any
|
||||||
|
|
||||||
|
test("filters out messages with empty string content", () => {
|
||||||
|
const msgs = [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: "" },
|
||||||
|
{ role: "user", content: "World" },
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].content).toBe("Hello")
|
||||||
|
expect(result[1].content).toBe("World")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out empty text parts from array content", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content).toHaveLength(1)
|
||||||
|
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out empty reasoning parts from array content", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "" },
|
||||||
|
{ type: "text", text: "Answer" },
|
||||||
|
{ type: "reasoning", text: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content).toHaveLength(1)
|
||||||
|
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes entire message when all parts are empty", () => {
|
||||||
|
const msgs = [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
{ type: "reasoning", text: "" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "user", content: "World" },
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].content).toBe("Hello")
|
||||||
|
expect(result[1].content).toBe("World")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps non-text/reasoning parts even if text parts are empty", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
{ type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content).toHaveLength(1)
|
||||||
|
expect(result[0].content[0]).toEqual({
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "123",
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps messages with valid text alongside empty parts", () => {
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "Thinking..." },
|
||||||
|
{ type: "text", text: "" },
|
||||||
|
{ type: "text", text: "Result" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].content).toHaveLength(2)
|
||||||
|
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
|
||||||
|
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not filter for non-anthropic providers", () => {
|
||||||
|
const openaiModel = {
|
||||||
|
...anthropicModel,
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-4",
|
||||||
|
url: "https://api.openai.com",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgs = [
|
||||||
|
{ role: "assistant", content: "" },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "" }],
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, openaiModel)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0].content).toBe("")
|
||||||
|
expect(result[1].content).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.variants", () => {
|
describe("ProviderTransform.variants", () => {
|
||||||
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
||||||
id: "test/test-model",
|
id: "test/test-model",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
ExperimentalResourceListResponses,
|
ExperimentalResourceListResponses,
|
||||||
FileListResponses,
|
FileListResponses,
|
||||||
FilePartInput,
|
FilePartInput,
|
||||||
|
FilePartSource,
|
||||||
FileReadResponses,
|
FileReadResponses,
|
||||||
FileStatusResponses,
|
FileStatusResponses,
|
||||||
FindFilesResponses,
|
FindFilesResponses,
|
||||||
@@ -1451,6 +1452,14 @@ export class Session extends HeyApiClient {
|
|||||||
arguments?: string
|
arguments?: string
|
||||||
command?: string
|
command?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
|
parts?: Array<{
|
||||||
|
id?: string
|
||||||
|
type: "file"
|
||||||
|
mime: string
|
||||||
|
filename?: string
|
||||||
|
url: string
|
||||||
|
source?: FilePartSource
|
||||||
|
}>
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -1467,6 +1476,7 @@ export class Session extends HeyApiClient {
|
|||||||
{ in: "body", key: "arguments" },
|
{ in: "body", key: "arguments" },
|
||||||
{ in: "body", key: "command" },
|
{ in: "body", key: "command" },
|
||||||
{ in: "body", key: "variant" },
|
{ in: "body", key: "variant" },
|
||||||
|
{ in: "body", key: "parts" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1222,6 +1222,7 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf
|
|||||||
|
|
||||||
export type PermissionConfig =
|
export type PermissionConfig =
|
||||||
| {
|
| {
|
||||||
|
__originalKeys?: Array<string>
|
||||||
read?: PermissionRuleConfig
|
read?: PermissionRuleConfig
|
||||||
edit?: PermissionRuleConfig
|
edit?: PermissionRuleConfig
|
||||||
glob?: PermissionRuleConfig
|
glob?: PermissionRuleConfig
|
||||||
@@ -1237,7 +1238,7 @@ export type PermissionConfig =
|
|||||||
codesearch?: PermissionActionConfig
|
codesearch?: PermissionActionConfig
|
||||||
lsp?: PermissionRuleConfig
|
lsp?: PermissionRuleConfig
|
||||||
doom_loop?: PermissionActionConfig
|
doom_loop?: PermissionActionConfig
|
||||||
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
|
[key: string]: PermissionRuleConfig | Array<string> | PermissionActionConfig | undefined
|
||||||
}
|
}
|
||||||
| PermissionActionConfig
|
| PermissionActionConfig
|
||||||
|
|
||||||
@@ -3292,6 +3293,14 @@ export type SessionCommandData = {
|
|||||||
arguments: string
|
arguments: string
|
||||||
command: string
|
command: string
|
||||||
variant?: string
|
variant?: string
|
||||||
|
parts?: Array<{
|
||||||
|
id?: string
|
||||||
|
type: "file"
|
||||||
|
mime: string
|
||||||
|
filename?: string
|
||||||
|
url: string
|
||||||
|
source?: FilePartSource
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2667,6 +2667,38 @@
|
|||||||
},
|
},
|
||||||
"variant": {
|
"variant": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"parts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "file"
|
||||||
|
},
|
||||||
|
"mime": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"$ref": "#/components/schemas/FilePartSource"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "mime", "url"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["arguments", "command"]
|
"required": ["arguments", "command"]
|
||||||
@@ -8221,6 +8253,12 @@
|
|||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"__originalKeys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"read": {
|
"read": {
|
||||||
"$ref": "#/components/schemas/PermissionRuleConfig"
|
"$ref": "#/components/schemas/PermissionRuleConfig"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
@@ -205,6 +205,41 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Provider-Specific Options
|
||||||
|
|
||||||
|
Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings.
|
||||||
|
|
||||||
|
##### Amazon Bedrock
|
||||||
|
|
||||||
|
Amazon Bedrock supports AWS-specific configuration:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
"options": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"profile": "my-aws-profile",
|
||||||
|
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `region` - AWS region for Bedrock (defaults to `AWS_REGION` env var or `us-east-1`)
|
||||||
|
- `profile` - AWS named profile from `~/.aws/credentials` (defaults to `AWS_PROFILE` env var)
|
||||||
|
- `endpoint` - Custom endpoint URL for VPC endpoints. This is an alias for the generic `baseURL` option using AWS-specific terminology. If both are specified, `endpoint` takes precedence.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over profile-based authentication. See [authentication precedence](/docs/providers#authentication-precedence) for details.
|
||||||
|
:::
|
||||||
|
|
||||||
|
[Learn more about Amazon Bedrock configuration](/docs/providers#amazon-bedrock).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
|
|
||||||
You can configure the theme you want to use in your OpenCode config through the `theme` option.
|
You can configure the theme you want to use in your OpenCode config through the `theme` option.
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Or you can set it up manually.
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run OpenCode
|
- name: Run OpenCode
|
||||||
uses: anomalyco/opencode/github@latest
|
uses: anomalyco/opencode/github@latest
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
issues: read
|
issues: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: anomalyco/opencode/github@latest
|
- uses: anomalyco/opencode/github@latest
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
@@ -221,7 +221,7 @@ jobs:
|
|||||||
return days >= 30;
|
return days >= 30;
|
||||||
result-encoding: string
|
result-encoding: string
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
if: steps.check.outputs.result == 'true'
|
if: steps.check.outputs.result == 'true'
|
||||||
|
|
||||||
- uses: anomalyco/opencode/github@latest
|
- uses: anomalyco/opencode/github@latest
|
||||||
|
|||||||
@@ -107,27 +107,96 @@ To use Amazon Bedrock with OpenCode:
|
|||||||
You need to have access to the model you want in Amazon Bedrock.
|
You need to have access to the model you want in Amazon Bedrock.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
1. You'll need either to set one of the following environment variables:
|
2. **Configure authentication** using one of the following methods:
|
||||||
- `AWS_ACCESS_KEY_ID`: You can get this by creating an IAM user and generating
|
|
||||||
an access key for it.
|
|
||||||
- `AWS_PROFILE`: First login through AWS IAM Identity Center (or AWS SSO) using
|
|
||||||
`aws sso login`. Then get the name of the profile you want to use.
|
|
||||||
- `AWS_BEARER_TOKEN_BEDROCK`: You can generate a long-term API key from the
|
|
||||||
Amazon Bedrock console.
|
|
||||||
|
|
||||||
Once you have one of the above, set it while running opencode.
|
#### Environment Variables (Quick Start)
|
||||||
|
|
||||||
|
Set one of these environment variables while running opencode:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AWS_ACCESS_KEY_ID=XXX opencode
|
# Option 1: Using AWS access keys
|
||||||
|
AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode
|
||||||
|
|
||||||
|
# Option 2: Using named AWS profile
|
||||||
|
AWS_PROFILE=my-profile opencode
|
||||||
|
|
||||||
|
# Option 3: Using Bedrock bearer token
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK=XXX opencode
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add it to your bash profile.
|
Or add them to your bash profile:
|
||||||
|
|
||||||
```bash title="~/.bash_profile"
|
```bash title="~/.bash_profile"
|
||||||
export AWS_ACCESS_KEY_ID=XXX
|
export AWS_PROFILE=my-dev-profile
|
||||||
|
export AWS_REGION=us-east-1
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Run the `/models` command to select the model you want.
|
#### Configuration File (Recommended)
|
||||||
|
|
||||||
|
For project-specific or persistent configuration, use `opencode.json`:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
"options": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"profile": "my-aws-profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available options:**
|
||||||
|
- `region` - AWS region (e.g., `us-east-1`, `eu-west-1`)
|
||||||
|
- `profile` - AWS named profile from `~/.aws/credentials`
|
||||||
|
- `endpoint` - Custom endpoint URL for VPC endpoints (alias for generic `baseURL` option)
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Configuration file options take precedence over environment variables.
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Advanced: VPC Endpoints
|
||||||
|
|
||||||
|
If you're using VPC endpoints for Bedrock:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"amazon-bedrock": {
|
||||||
|
"options": {
|
||||||
|
"region": "us-east-1",
|
||||||
|
"profile": "production",
|
||||||
|
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
The `endpoint` option is an alias for the generic `baseURL` option, using AWS-specific terminology. If both `endpoint` and `baseURL` are specified, `endpoint` takes precedence.
|
||||||
|
:::
|
||||||
|
|
||||||
|
#### Authentication Methods
|
||||||
|
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Create an IAM user and generate access keys in the AWS Console
|
||||||
|
- **`AWS_PROFILE`**: Use named profiles from `~/.aws/credentials`. First configure with `aws configure --profile my-profile` or `aws sso login`
|
||||||
|
- **`AWS_BEARER_TOKEN_BEDROCK`**: Generate long-term API keys from the Amazon Bedrock console
|
||||||
|
|
||||||
|
#### Authentication Precedence
|
||||||
|
|
||||||
|
Amazon Bedrock uses the following authentication priority:
|
||||||
|
1. **Bearer Token** - `AWS_BEARER_TOKEN_BEDROCK` environment variable or token from `/connect` command
|
||||||
|
2. **AWS Credential Chain** - Profile, access keys, shared credentials, IAM roles, instance metadata
|
||||||
|
|
||||||
|
:::note
|
||||||
|
When a bearer token is set (via `/connect` or `AWS_BEARER_TOKEN_BEDROCK`), it takes precedence over all AWS credential methods including configured profiles.
|
||||||
|
:::
|
||||||
|
|
||||||
|
3. Run the `/models` command to select the model you want.
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
/models
|
/models
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user