Compare commits

...
34 Commits
Author SHA1 Message Date
opencode 4f49967518 release: v1.1.3
Update Nix Hashes / update (push) Has been cancelled
2026-01-05 19:26:40 +00:00
Adam ec637aa21e fix(app): store image attachments 2026-01-05 13:21:33 -06:00
Adam 2ff9a757b6 fix(app): bottom padding 2026-01-05 13:21:32 -06:00
Adam cb8533ef5b fix(app): terminal long-running processes 2026-01-05 13:21:32 -06:00
Adam cdbb009ab0 fix(app): terminal flakiness 2026-01-05 13:21:31 -06:00
Adam 001b486356 fix(app): performance improvements through event batching 2026-01-05 13:21:30 -06:00
Adam d315026abc fix(app): prompt input shouldn't cover content 2026-01-05 13:21:30 -06:00
Adam d1191675c6 fix(app): worktree selection should navigate to worktree 2026-01-05 13:21:29 -06:00
GitHub Action 362a657b4f chore: generate 2026-01-05 19:10:42 +00:00
Dax Raad 0276885181 core: preserve permission config key order to maintain user-defined permission precedence 2026-01-05 14:09:37 -05:00
Guorui Yu 5a38a6f248 tui: autocomplete: expand directory on Tab, select on Enter (#6975)
Signed-off-by: yuguorui <yuguorui@pku.edu.cn>
2026-01-05 13:08:17 -06:00
GitHub Action aef01003e7 chore: generate 2026-01-05 19:07:34 +00:00
Melih MucukandMelih Mucuk a38e1701ee fix: pass image parts to custom commands (#6525)
Co-authored-by: Melih Mucuk <melih@monkeysteam.com>
2026-01-05 13:06:57 -06:00
ikeda-tomoya-swx bf9ee32d4a fix(provider): add support for jp. prefix in Amazon Bedrock cross-region inference profiles (#6916) 2026-01-05 13:03:09 -06:00
Josh Thomas 0917991361 docs: update GHA examples to use actions/checkout@v6 (#6969)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2026-01-05 13:00:22 -06:00
Ravi KumarandAiden Cline c6a241e331 ci: prevent duplicate PR check from flagging current PR as duplicate (#6924)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-05 12:59:42 -06:00
Rohan Mukherjee 4b7301e8ca fix: lucent-orng bg transparency for slash commands (#6938) 2026-01-05 12:56:49 -06:00
Daniel Vélez 1bf20f0a2b docs: add description for MCP command (#6944) 2026-01-05 12:56:00 -06:00
Grégoire Morpain e3b4d4ad49 feat(bedrock): config options and authentication precedence (#6377) 2026-01-05 12:51:43 -06:00
MogamiTsuchikawa 6b207b09d6 fix(app): avoid unintended submits during IME composition (#6952) 2026-01-05 12:38:38 -06:00
Albin Groen 9771325026 feat(app): highlight collapsed active project in sidebar (#6958) 2026-01-05 12:37:46 -06:00
Albin Groen bbd1c071c4 fix(app): fix flicker and navigation when collapsing/expanding projects (#6658) 2026-01-05 11:24:49 -06:00
Daniel PolitoandBrendan Allan 8e9a0c4ad0 Desktop: Install CLI (#6526)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-01-06 01:07:46 +08:00
Frank ced093e646 sync 2026-01-05 11:59:17 -05:00
Frank 283bdce358 sync 2026-01-05 11:13:59 -05:00
Dax Raad 91d5ce8bf3 tui: add system theme resolution and event handling 2026-01-05 10:38:35 -05:00
Frank 7f870cc9d4 wip: zen 2026-01-05 10:16:47 -05:00
David HillandDax 2cb3b0484b fix: cleaner interrupted experience (#6785)
Co-authored-by: Dax <mail@thdxr.com>
2026-01-05 09:53:19 -05:00
Frank 11b0df6b86 wip: zen 2026-01-05 06:16:04 -05:00
Frank e15af828fa zen: optimize query 2026-01-05 05:58:39 -05:00
Albin Groen 265cbaea7c fix(app): fix image dragging in project edit dialog (#6700) 2026-01-05 04:54:11 -06:00
GitHub Action d39ebbc947 chore: generate 2026-01-05 07:41:02 +00:00
Aiden Cline 06acd70670 tweak: transform 2026-01-05 01:40:15 -06:00
Aiden Cline c285304acf fix: for anthropic compat ensure empty msgs and empty reasoning is filtered out 2026-01-05 01:40:15 -06:00
72 changed files with 1786 additions and 671 deletions
+2
View File
@@ -44,6 +44,8 @@ jobs:
{
echo "Check for duplicate PRs related to this new PR:"
echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo ""
echo "Description:"
+1 -1
View File
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
stats:
if: github.repository == 'sst/opencode'
if: github.repository == 'anomalyco/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+2
View File
@@ -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.
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.
If you find potential duplicates:
+5
View File
@@ -45,6 +45,11 @@ export default tool({
}
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")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
+3
View File
@@ -189,3 +189,6 @@
| 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-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) |
+15 -15
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.2",
"version": "1.1.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -349,7 +349,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -369,7 +369,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.2",
"version": "1.1.3",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -380,7 +380,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -393,7 +393,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -431,7 +431,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"zod": "catalog:",
},
@@ -442,7 +442,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
+143 -113
View File
@@ -16,16 +16,19 @@ Usage: install.sh [options]
Options:
-h, --help Display this help message
-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.)
Examples:
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF
}
requested_version=${VERSION:-}
no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
exit 1
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=true
shift
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
esac
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
mkdir -p "$INSTALL_DIR"
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}"
# If --binary is provided, skip all download/detection logic
if [ -n "$binary_path" ]; then
if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
exit 1
fi
specific_version="local"
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}"
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
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
@@ -267,11 +288,11 @@ download_with_progress() {
{
local length=0
local bytes=0
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
@@ -296,7 +317,7 @@ download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -307,14 +328,24 @@ download_and_install() {
else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi
mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir"
}
check_version
download_and_install
install_from_binary() {
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() {
@@ -416,4 +447,3 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.2",
"version": "1.1.3",
"description": "",
"type": "module",
"exports": {
+47
View File
@@ -242,6 +242,53 @@ describe("SerializeAddon", () => {
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 () => {
const { term, addon } = createTerminal(40, 5)
+19 -23
View File
@@ -157,23 +157,6 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler {
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 {
let oldCell = this._buffer.getNullCell()
@@ -182,14 +165,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.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++) {
const line = this._buffer.getLine(row)
if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
const endLineColumn = Math.min(maxColumn, line.length)
const endLineColumn = Math.min(endColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col)
if (!c) {
@@ -243,6 +226,13 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void {
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._lastCursorRow = start
this._firstRow = start
@@ -251,6 +241,11 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
@@ -388,7 +383,8 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
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 sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -397,7 +393,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
@@ -417,7 +413,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth()
} else {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
+56 -29
View File
@@ -1,5 +1,17 @@
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 { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -153,6 +165,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
)
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
@@ -160,7 +175,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: Prompt | null
placeholder: number
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
}>({
@@ -169,7 +183,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false,
imageAttachments: [],
mode: "normal",
applyingHistory: false,
})
@@ -246,6 +259,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const isFocused = createFocusSignal(() => editorRef)
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -260,21 +275,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mime: file.type,
dataUrl,
}
setStore(
produce((draft) => {
draft.imageAttachments.push(attachment)
}),
)
const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
prompt.set([...prompt.current(), attachment], cursorPosition)
}
reader.readAsDataURL(file)
}
const removeImageAttachment = (id: string) => {
setStore(
produce((draft) => {
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
}),
)
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
}
const handlePaste = async (event: ClipboardEvent) => {
@@ -302,6 +312,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDragOver = (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) {
@@ -310,6 +322,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (dialog.active) return
// relatedTarget is null when leaving the document window
if (!event.relatedTarget) {
setStore("dragging", false)
@@ -317,6 +331,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDrop = async (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
setStore("dragging", false)
@@ -518,8 +534,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
on(
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(currentParts, domParts)) return
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -527,7 +544,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(currentParts)
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
@@ -618,11 +635,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => {
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
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) {
setStore("popover", null)
@@ -661,7 +679,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
prompt.set(rawParts, cursorPosition)
prompt.set([...rawParts, ...images], cursorPosition)
queueScroll()
}
@@ -764,16 +782,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.map((p) => ("content" in p ? p.content : ""))
.join("")
.trim()
if (!text) return
const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return
const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0]
if (lastEntry) {
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
if (lastEntry && isPromptEqual(lastEntry, entry)) return
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 === "at") {
atOnKeyDown(event)
@@ -943,7 +963,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = store.imageAttachments.slice()
const images = imageAttachments().slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0) {
@@ -1037,14 +1057,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clearInput = () => {
prompt.reset()
setStore("imageAttachments", [])
setStore("mode", "normal")
setStore("popover", null)
}
const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("imageAttachments", images)
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => {
@@ -1087,6 +1105,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
@@ -1440,9 +1465,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For>
</div>
</Show>
<Show when={store.imageAttachments.length > 0}>
<Show when={imageAttachments().length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}>
<For each={imageAttachments()}>
{(attachment) => (
<div class="relative group">
<Show
@@ -1483,6 +1508,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
contenteditable="true"
onInput={handleInput}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
@@ -1492,7 +1519,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"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">
{store.mode === "shell"
? "Enter shell command..."
@@ -1625,7 +1652,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
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 { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
@@ -36,6 +37,7 @@ export function SessionHeader() {
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
@@ -60,7 +62,7 @@ export function SessionHeader() {
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
@@ -23,11 +23,18 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection
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) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch
if (branch) return `Current branch (${branch})`
if (branch) return `Main branch (${branch})`
return "Main branch"
}
@@ -37,13 +44,16 @@ export function NewSessionView(props: NewSessionViewProps) {
}
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="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
</div>
</div>
<div class="flex justify-center items-center gap-1">
+59 -45
View File
@@ -36,12 +36,14 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
let term: Term
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -76,22 +78,24 @@ export const Terminal = (props: TerminalProps) => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
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
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) 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
return false
}
const handlePointerDown = () => {
const activeElement = document.activeElement
@@ -104,8 +108,12 @@ export const Terminal = (props: TerminalProps) => {
onMount(async () => {
ghostty = await Ghostty.load()
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
term = new Term({
const socket = new WebSocket(
sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`,
)
ws = socket
const t = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
@@ -114,7 +122,9 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000,
ghostty,
})
term.attachCustomKeyEventHandler((event) => {
term = t
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
@@ -134,30 +144,31 @@ export const Terminal = (props: TerminalProps) => {
fitAddon = new FitAddon()
serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
t.loadAddon(serializeAddon)
t.loadAddon(fitAddon)
term.open(container)
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) {
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()
term.write(local.pty.buffer)
if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
t.reset()
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) {
t.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
})
}
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -169,39 +180,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data)
t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
term.onKey((key) => {
t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
// term.onScroll((ydisp) => {
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
ws.addEventListener("open", () => {
socket.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
})
ws.addEventListener("message", (event) => {
term.write(event.data)
socket.addEventListener("message", (event) => {
t.write(event.data)
})
ws.addEventListener("error", (error) => {
socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
})
ws.addEventListener("close", () => {
socket.addEventListener("close", () => {
console.log("WebSocket disconnected")
})
})
@@ -211,18 +222,21 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = serializeAddon.serialize()
props.onCleanup({
...local.pty,
buffer,
rows: term.rows,
cols: term.cols,
scrollY: term.getViewportY(),
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
ws?.close()
term?.dispose()
t?.dispose()
})
return (
+70 -5
View File
@@ -1,7 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { batch, onCleanup } from "solid-js"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -19,14 +19,79 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
[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 () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) {
emitter.emit(event.directory ?? "global", event.payload)
}
})().catch(() => undefined)
const directory = event.directory ?? "global"
const payload = event.payload
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 sdk = createOpencodeClient({
+3 -2
View File
@@ -23,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
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 { getFilename } from "@opencode-ai/util/path"
@@ -212,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e))
}
globalSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -404,6 +404,7 @@ function createGlobalSync() {
}
}
})
onCleanup(unsub)
async function bootstrap() {
const health = await globalSDK.client.global
+42 -14
View File
@@ -1,11 +1,12 @@
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 { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist"
import { same } from "@/utils/same"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
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 = {
active?: string
all: string[]
@@ -97,8 +91,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...project,
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
@@ -115,6 +109,41 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
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 list = createMemo(() => enriched().flatMap(colorize))
@@ -131,11 +160,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
if (server.projects.list().find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
server.projects.open(directory)
const root = roots().get(directory) ?? directory
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
server.projects.close(directory)
+3 -2
View File
@@ -1,5 +1,5 @@
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 type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -465,7 +465,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) =>
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
switch (event.type) {
case "file.watcher.updated":
@@ -475,6 +475,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break
}
})
onCleanup(unsub)
return {
node: async (path: string) => {
+3 -1
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
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 event = e.details
const base = {
@@ -104,6 +105,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
}
})
onCleanup(unsub)
return {
ready,
+3 -1
View File
@@ -1,6 +1,7 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
@@ -20,9 +21,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[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)
})
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
+13 -6
View File
@@ -644,13 +644,13 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
const permissions = sessionStore.permission?.[props.session.id] ?? []
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) {
const childPermissions = store.permission?.[child.id] ?? []
const childPermissions = sessionStore.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
@@ -658,7 +658,7 @@ export default function Layout(props: ParentProps) {
const isWorking = createMemo(() => {
if (props.session.id === params.id) 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 (
@@ -759,6 +759,10 @@ export default function Layout(props: ParentProps) {
const isExpanded = createMemo(() =>
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) => {
if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree)
@@ -777,7 +781,10 @@ export default function Layout(props: ParentProps) {
<Button
as={"div"}
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">
<ProjectAvatar
+63 -17
View File
@@ -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 { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
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 { useLayout } from "@/context/layout"
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 { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -48,12 +49,7 @@ import {
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
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])
}
import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
@@ -253,6 +249,14 @@ export default function Page() {
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
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(() => {
@@ -287,6 +291,8 @@ export default function Page() {
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
createEffect(() => {
if (!params.id) return
@@ -666,9 +672,30 @@ export default function Page() {
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = 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) => {
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,
"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">
<Switch>
@@ -789,7 +819,7 @@ export default function Page() {
view={view}
diffStyle="unified"
classes={{
root: "pb-32",
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
@@ -820,7 +850,7 @@ export default function Page() {
>
<div
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={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
@@ -833,9 +863,9 @@ export default function Page() {
data-message-id={message.id}
classList={{
"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",
"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",
}}
>
@@ -871,15 +901,31 @@ export default function Page() {
</Match>
<Match when={true}>
<NewSessionView
worktree={store.newSessionWorktree}
onWorktreeChange={(value) => setStore("newSessionWorktree", value)}
worktree={newSessionWorktree()}
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>
</Switch>
</div>
{/* 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
classList={{
"w-full md:px-6 pointer-events-auto": true,
@@ -890,7 +936,7 @@ export default function Page() {
ref={(el) => {
inputRef = el
}}
newSessionWorktree={store.newSessionWorktree}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</div>
+166 -28
View File
@@ -1,47 +1,185 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
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.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = []
let position = 0
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "text") {
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") {
if (part.type === "file") {
const filePart = part as FilePart
if (filePart.source?.type === "file") {
const path = filePart.source.path
const content = "@" + path
const attachment: FileAttachmentPart = {
type: "file",
path,
content,
start: position,
end: position + content.length,
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
result.push(attachment)
position += content.length
inline.push({
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) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
return result
if (images.length === 0) return result
return [...result, ...images]
}
+6
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"scripts": {
@@ -172,8 +172,8 @@ export async function handler(
const tokensInfo = providerInfo.normalizeUsage(json.usage)
await trialLimiter?.track(tokensInfo)
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
return new Response(body, {
status: resStatus,
statusText: res.statusText,
@@ -206,8 +206,8 @@ export async function handler(
if (usage) {
const tokensInfo = providerInfo.normalizeUsage(usage)
await trialLimiter?.track(tokensInfo)
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo)
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
await reload(authInfo, costInfo)
}
c.close()
return
@@ -392,6 +392,7 @@ export async function handler(
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger,
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
},
user: {
id: UserTable.id,
@@ -560,61 +561,68 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
await Database.use((db) =>
Promise.all([
db.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
keyID: authInfo.apiKeyId,
}),
db
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
await tx
.update(UserTable)
.set({
monthlyUsage: sql`
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
db
.update(UserTable)
.set({
monthlyUsage: sql`
CASE
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
db
.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.isFree) 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) =>
tx
.update(BillingTable)
@@ -625,10 +633,7 @@ export async function handler(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
),
lt(BillingTable.balance, reloadTrigger),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.2",
"version": "1.1.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.2",
"version": "1.1.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.2",
"version": "1.1.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"scripts": {
+1
View File
@@ -2777,6 +2777,7 @@ version = "0.0.0"
dependencies = [
"gtk",
"listeners",
"semver",
"serde",
"serde_json",
"tauri",
+1
View File
@@ -35,6 +35,7 @@ serde_json = "1"
tokio = "1.48.0"
listeners = "0.3"
tauri-plugin-os = "2"
semver = "1.0.27"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
+116
View File
@@ -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(())
}
+83 -70
View File
@@ -1,12 +1,16 @@
mod cli;
mod window_customizer;
use cli::{get_sidecar_path, install_cli, sync_cli};
use std::{
collections::VecDeque,
net::{SocketAddr, TcpListener},
sync::{Arc, Mutex},
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_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
#[cfg(not(target_os = "windows"))]
let (mut rx, child) = {
let sidecar_path = tauri::utils::platform::current_exe()
.expect("Failed to get current exe")
.parent()
.expect("Failed to get parent dir")
.join("opencode-cli");
let sidecar = get_sidecar_path();
let shell = get_user_shell();
app.shell()
.command(&shell)
@@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
.args([
"-il",
"-c",
&format!("{} serve --port={}", sidecar_path.display(), port),
&format!("{} serve --port={}", sidecar.display(), port),
])
.spawn()
.expect("Failed to spawn opencode")
@@ -203,7 +203,8 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
kill_sidecar,
copy_logs_to_clipboard,
get_logs
get_logs,
install_cli
])
.setup(move |app| {
let app = app.handle().clone();
@@ -211,83 +212,95 @@ pub fn run() {
// Initialize log state
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 = spawn_sidecar(&app, port);
let child = if should_spawn_sidecar {
let child = spawn_sidecar(&app, port);
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
let res = app.dialog()
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
let timestamp = Instant::now();
loop {
if timestamp.elapsed() > Duration::from_secs(7) {
let res = app.dialog()
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
.title("Startup Failed")
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
.blocking_show_with_result();
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"),
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
}
}
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
match copy_logs_to_clipboard(app.clone()).await {
Ok(()) => println!("Logs copied to clipboard successfully"),
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 {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
break;
}
}
break;
}
}
println!("Server ready after {:?}", timestamp.elapsed());
println!("Server ready after {:?}", timestamp.elapsed());
Some(child)
} else {
None
};
Some(child)
} else {
None
};
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
window.__OPENCODE__.port = {port};
"#
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.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(())
});
+13
View File
@@ -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" })
}
}
+5
View File
@@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men
import { type as ostype } from "@tauri-apps/plugin-os"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
export async function createMenu() {
if (ostype() !== "macos") return
@@ -19,6 +20,10 @@ export async function createMenu() {
action: () => runUpdater({ alertOnFail: true }),
text: "Check For Updates...",
}),
await MenuItem.new({
action: () => installCli(),
text: "Install CLI...",
}),
await PredefinedMenuItem.new({
item: "Separator",
}),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.2",
"version": "1.1.3",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.2"
version = "1.1.3"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.2/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.3/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
[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"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.2",
"version": "1.1.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.2",
"version": "1.1.3",
"name": "opencode",
"type": "module",
"license": "MIT",
+5 -1
View File
@@ -335,7 +335,11 @@ export const AuthLoginCommand = cmd({
if (provider === "amazon-bedrock") {
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")
return
+1 -1
View File
@@ -393,7 +393,7 @@ jobs:
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run opencode
uses: anomalyco/opencode/github@latest${envStr}
+1
View File
@@ -50,6 +50,7 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
export const McpCommand = cmd({
command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
builder: (yargs) =>
yargs
.command(McpAddCommand)
@@ -569,6 +569,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = (() => {
if (!error) return "An error occurred"
@@ -55,6 +55,7 @@ export type AutocompleteOption = {
aliases?: string[]
disabled?: boolean
description?: string
isDirectory?: boolean
onSelect?: () => void
}
@@ -200,8 +201,10 @@ export function Autocomplete(props: {
url = urlObj.toString()
}
const isDir = item.endsWith("/")
return {
display: Locale.truncateMiddle(filename, width),
isDirectory: isDir,
onSelect: () => {
insertPart(filename, {
type: "file",
@@ -511,6 +514,27 @@ export function Autocomplete(props: {
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: "@" | "/") {
command.keybinds(false)
setStore({
@@ -575,11 +599,21 @@ export function Autocomplete(props: {
e.preventDefault()
return
}
if (name === "return" || name === "tab") {
if (name === "return") {
select()
e.preventDefault()
return
}
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
}
if (!store.visible) {
if (e.name === "@") {
@@ -559,6 +559,12 @@ export function Prompt(props: PromptProps) {
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
})
} else {
sdk.client.session.prompt({
@@ -40,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useSDK } from "./sdk"
type ThemeColors = {
primary: RGBA
@@ -310,32 +311,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
})
const renderer = useRenderer()
renderer
.getPalette({
size: 16,
})
.then((colors) => {
if (!colors.palette[0]) {
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "opencode"
draft.ready = true
}),
)
}
return
}
setStore(
produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode)
function resolveSystemTheme() {
console.log("resolved system theme")
renderer
.getPalette({
size: 16,
})
.then((colors) => {
if (!colors.palette[0]) {
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(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
@@ -11,6 +11,7 @@
"darkBlue": "#6ba1e6",
"darkCyan": "#56b6c2",
"darkYellow": "#e5c07b",
"darkPanelBg": "#2a1a1599",
"lightStep6": "#d4d4d4",
"lightStep11": "#8a8a8a",
"lightStep12": "#1a1a1a",
@@ -20,7 +21,8 @@
"lightOrange": "#EC5B2B",
"lightBlue": "#0062d1",
"lightCyan": "#318795",
"lightYellow": "#b0851f"
"lightYellow": "#b0851f",
"lightPanelBg": "#fff5f099"
},
"theme": {
"primary": {
@@ -71,6 +73,10 @@
"dark": "transparent",
"light": "transparent"
},
"backgroundMenu": {
"dark": "darkPanelBg",
"light": "lightPanelBg"
},
"border": {
"dark": "darkOrange",
"light": "lightOrange"
@@ -1204,7 +1204,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
)
}}
</For>
<Show when={props.message.error}>
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
<box
border={["left"]}
paddingTop={1}
@@ -1219,15 +1219,27 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
</box>
</Show>
<Switch>
<Match when={props.last || final()}>
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
<box paddingLeft={3}>
<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.textMuted }}> · {props.message.modelID}</span>
<Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={props.message.error?.name === "MessageAbortedError"}>
<span style={{ fg: theme.textMuted }}> · interrupted</span>
</Show>
</text>
</box>
</Match>
+45 -20
View File
@@ -395,27 +395,52 @@ export namespace Config {
})
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
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)
.transform((x) => (typeof x === "string" ? { "*": x } : x))
.preprocess(
permissionPreprocess,
z
.object({
__originalKeys: z.string().array().optional(),
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction),
)
.transform(permissionTransform)
.meta({
ref: "PermissionConfig",
})
+34 -12
View File
@@ -15,7 +15,7 @@ import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
// 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 { createAzure } from "@ai-sdk/azure"
import { createGoogleGenerativeAI } from "@ai-sdk/google"
@@ -168,10 +168,22 @@ export namespace Provider {
}
},
"amazon-bedrock": async () => {
const config = await Config.get()
const providerConfig = config.provider?.["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 awsRegion = Env.get("AWS_REGION")
const awsBearerToken = iife(() => {
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
@@ -183,20 +195,30 @@ export namespace Provider {
return undefined
})
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
const defaultRegion = awsRegion ?? "us-east-1"
if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
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 {
autoload: true,
options: {
region: defaultRegion,
credentialProvider: fromNodeProviderChain(),
},
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has global prefix
if (modelID.startsWith("global.")) {
// Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
return sdk.languageModel(modelID)
}
@@ -17,6 +17,28 @@ function mimeToModality(mime: string): Modality | undefined {
export namespace ProviderTransform {
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")) {
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
+24 -7
View File
@@ -12,6 +12,9 @@ import { Shell } from "@/shell/shell"
export namespace Pty {
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
return spawn
@@ -126,15 +129,19 @@ export namespace Pty {
}
state().set(id, session)
ptyProcess.onData((data) => {
if (session.subscribers.size === 0) {
session.buffer += data
return
}
let open = false
for (const ws of session.subscribers) {
if (ws.readyState === 1) {
ws.send(data)
if (ws.readyState !== 1) {
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 }) => {
log.info("session exited", { id, exitCode })
@@ -196,8 +203,18 @@ export namespace Pty {
log.info("client connected to session", { id })
session.subscribers.add(ws)
if (session.buffer) {
ws.send(session.buffer)
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
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 {
onMessage: (message: string | ArrayBuffer) => {
+17 -3
View File
@@ -1422,10 +1422,23 @@ export namespace SessionPrompt {
arguments: z.string(),
command: z.string(),
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>
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 quoteTrimRegex = /^["']|["']$/g
/**
@@ -1516,6 +1529,7 @@ export namespace SessionPrompt {
throw error
}
const templateParts = await resolvePromptParts(template)
const parts =
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
? [
@@ -1525,10 +1539,10 @@ export namespace SessionPrompt {
description: command.description ?? "",
command: input.command,
// 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({
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_*",
])
},
})
})
+2
View File
@@ -42,6 +42,8 @@ delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
delete process.env["AZURE_OPENAI_API_KEY"]
delete process.env["AWS_ACCESS_KEY_ID"]
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["GROQ_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 { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Env } from "../../src/env"
import { Auth } from "../../src/auth"
import { Global } from "../../src/global"
// === Mocks ===
// These mocks are required because Provider.list() triggers:
// 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
// 2. Plugin.list() which calls BunProc.install() for default plugins
// 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 () => {
await using tmp = await tmpdir({
@@ -34,13 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
// Region from config should be used (not env var)
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({
init: async (dir) => {
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 () => {
await using tmp = await tmpdir({
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")
await Bun.write(
authPath,
@@ -222,7 +126,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
await Instance.provide({
directory: tmp.path,
init: async () => {
// Clear env vars so only auth.json should trigger autoload
Env.set("AWS_PROFILE", "")
Env.set("AWS_ACCESS_KEY_ID", "")
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", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"scripts": {
+10
View File
@@ -24,6 +24,7 @@ import type {
ExperimentalResourceListResponses,
FileListResponses,
FilePartInput,
FilePartSource,
FileReadResponses,
FileStatusResponses,
FindFilesResponses,
@@ -1451,6 +1452,14 @@ export class Session extends HeyApiClient {
arguments?: string
command?: string
variant?: string
parts?: Array<{
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}>
},
options?: Options<never, ThrowOnError>,
) {
@@ -1467,6 +1476,7 @@ export class Session extends HeyApiClient {
{ in: "body", key: "arguments" },
{ in: "body", key: "command" },
{ in: "body", key: "variant" },
{ in: "body", key: "parts" },
],
},
],
+10 -1
View File
@@ -1222,6 +1222,7 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf
export type PermissionConfig =
| {
__originalKeys?: Array<string>
read?: PermissionRuleConfig
edit?: PermissionRuleConfig
glob?: PermissionRuleConfig
@@ -1237,7 +1238,7 @@ export type PermissionConfig =
codesearch?: PermissionActionConfig
lsp?: PermissionRuleConfig
doom_loop?: PermissionActionConfig
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
[key: string]: PermissionRuleConfig | Array<string> | PermissionActionConfig | undefined
}
| PermissionActionConfig
@@ -3292,6 +3293,14 @@ export type SessionCommandData = {
arguments: string
command: string
variant?: string
parts?: Array<{
id?: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}>
}
path: {
/**
+38
View File
@@ -2667,6 +2667,38 @@
},
"variant": {
"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"]
@@ -8221,6 +8253,12 @@
{
"type": "object",
"properties": {
"__originalKeys": {
"type": "array",
"items": {
"type": "string"
}
},
"read": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.2",
"version": "1.1.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.2",
"version": "1.1.3",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+35
View File
@@ -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
You can configure the theme you want to use in your OpenCode config through the `theme` option.
+4 -4
View File
@@ -58,7 +58,7 @@ Or you can set it up manually.
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 1
@@ -134,7 +134,7 @@ jobs:
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Run OpenCode
uses: anomalyco/opencode/github@latest
@@ -171,7 +171,7 @@ jobs:
pull-requests: read
issues: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -221,7 +221,7 @@ jobs:
return days >= 30;
result-encoding: string
- uses: actions/checkout@v4
- uses: actions/checkout@v6
if: steps.check.outputs.result == 'true'
- uses: anomalyco/opencode/github@latest
+81 -12
View File
@@ -107,27 +107,96 @@ To use Amazon Bedrock with OpenCode:
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:
- `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.
2. **Configure authentication** using one of the following methods:
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
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"
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
/models
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.1.2",
"version": "1.1.3",
"publisher": "sst-dev",
"repository": {
"type": "git",