Compare commits

..
164 Commits
Author SHA1 Message Date
opencode 504a599473 release: v0.15.30 2025-10-31 06:12:16 +00:00
Dax Raad 750b9f80a5 sync 2025-10-31 02:00:11 -04:00
Aiden Cline dfdd009750 fix: bash permission case 2025-10-31 00:52:46 -05:00
c1ada302f9 fix: Opencode hangs after exit (#3481)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-30 23:57:58 -05:00
FilipandGitHub 51e4c9fc4c add optional headers field to model config (#3546) 2025-10-30 23:35:26 -05:00
Jay V 43e272e6c4 ignore: refactor header context menu to use CSS styling and router navigation 2025-10-30 20:24:29 -04:00
David Hill 2f9f189f39 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-10-30 22:53:00 +00:00
David Hill f3c70f4ea8 Asset updates 2025-10-30 22:52:56 +00:00
David Hill 5d4441cd2b Fix download zip 2025-10-30 22:41:32 +00:00
David Hill bf5f34ace7 Brand Assets Zip 2025-10-30 22:38:12 +00:00
David Hill 9589657d21 Configure png and svg downloads 2025-10-30 22:25:07 +00:00
David Hill 37baed99c1 Brand page 2025-10-30 22:13:48 +00:00
a3ba740de4 fix: resolve hanging permission prompts in headless mode (#3522)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-30 15:37:41 -05:00
Adam dc96664578 chore: sanitize tool parts locally 2025-10-30 14:57:38 -05:00
Jay V 4dafc532a8 ignore: update project stats to reflect current growth 2025-10-30 15:47:41 -04:00
Adam 984fe4b769 wip: desktop work 2025-10-30 14:46:38 -05:00
Adam 48f50cf55e wip: desktop work 2025-10-30 14:41:15 -05:00
Frank ba13f8da08 wip: fix 2025-10-30 15:15:46 -04:00
Frank 1a8b494055 wip: zen 2025-10-30 15:10:29 -04:00
Frank 4f02d7d424 zen: allow byok requests w/o a balance 2025-10-30 15:10:29 -04:00
Adam 4cebd69bf0 wip: desktop work 2025-10-30 13:54:52 -05:00
Adam dc6e54503c wip: desktop work 2025-10-30 13:49:29 -05:00
David Hill f18847d739 Adding links to legal 2025-10-30 17:37:30 +00:00
Adam 2a0b67d84f fix: lander space 2025-10-30 12:32:25 -05:00
Adam 89eac737a5 wip: desktop work 2025-10-30 12:30:45 -05:00
c68607fb2b feat: Adds session id to Agent context metadata (#3559)
Co-authored-by: Brandon Wisnicki <bwisnicki@palantir.com>
2025-10-30 12:15:21 -05:00
Adam e944ff0286 wip: desktop work 2025-10-30 12:13:02 -05:00
Adam ee7612a31c wip: desktop work 2025-10-30 12:02:51 -05:00
Adam 582ed7c363 wip: desktop work 2025-10-30 12:02:50 -05:00
Adam dce287a42d wip: desktop work 2025-10-30 12:02:50 -05:00
Adam 19974daa67 wip: desktop work 2025-10-30 12:02:50 -05:00
Adam dcf865a889 wip: desktop work 2025-10-30 12:02:50 -05:00
Adam 3b20935959 wip: desktop work 2025-10-30 12:02:50 -05:00
Adam 30f4c2cf4c wip: desktop work 2025-10-30 12:02:49 -05:00
Aiden ClineandGitHub 3541fdcb20 tweak: adjust deno lsp (#3581) 2025-10-30 11:59:54 -05:00
David Hill 15de97c10f Change position 2025-10-30 16:55:23 +00:00
Jay V ee3fd3f7be ignore:lander 2025-10-30 12:40:07 -04:00
Haris GušićandGitHub dc87659791 tweak: move zod validation for tools to ensure it always runs (#3565) 2025-10-30 11:31:44 -05:00
Andrew PashynnykandGitHub 149f5eaa2e fix: preserve metadata from MCP tool results in tool.execute.after hook (#3573) 2025-10-30 11:10:42 -05:00
Aiden Cline 42e0b47a7d fix: better frontmatter errors 2025-10-30 10:56:40 -05:00
oribiandGitHub 2d5df3ad76 fix: agent model selection priority issue (#3572) 2025-10-30 09:32:39 -05:00
David Hill f202fa0d89 Theme aware svgs to be copied 2025-10-30 13:01:48 +00:00
David Hill 0abffdb8f8 Icon size update 2025-10-30 12:46:34 +00:00
David Hill e533d48b51 New assets 2025-10-30 12:36:41 +00:00
David Hill 439372704d Close content menu with escape 2025-10-30 12:19:26 +00:00
David Hill d7277fd305 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-10-30 12:16:15 +00:00
GitHub Action 5ae73637d3 ignore: update download stats 2025-10-30 2025-10-30 12:04:36 +00:00
David Hill bf0cbf2bfa Adding polish 2025-10-30 11:37:00 +00:00
opencode 4b3a841dd9 release: v0.15.29 2025-10-30 06:49:34 +00:00
Aiden Cline aca32eaa1c fix: tui not showing some errors 2025-10-30 01:41:28 -05:00
Matt GillardGitHubMatt Gillardopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
3ae75d7031 add optional timeout field to mcp config to allow users to use responding servers (#3558)
Co-authored-by: Matt Gillard <matt-github@gillard.biz>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-30 01:25:24 -05:00
Khang Ha (Kelvin)andGitHub 4b5e447961 Fix "bufio.Scanner token too long" error by replacing Scanner with Reader in SSE (#3531) 2025-10-30 01:04:06 -05:00
Aiden Cline 7a2b8eae76 tweak: catch err 2025-10-30 00:29:30 -05:00
d983b9485d fix: add doom loop detection (#3445)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-30 00:13:18 -05:00
GitHub Action 14836de276 ignore: update download stats 2025-10-30 2025-10-30 02:28:13 +00:00
David Hill e265efec09 Add right click menu to logo 2025-10-30 00:40:24 +00:00
David Hill 5ae00ba567 Brand page 2025-10-30 00:22:49 +00:00
Jay V a0f032c9b9 ignore: fix email formatting to ensure proper line breaks between message and signature 2025-10-29 19:19:28 -04:00
Jay V e6132fc6a4 docs: enterprise 2025-10-29 19:07:43 -04:00
Frank 950b608c4d zen: show browser default time 2025-10-29 17:14:28 -04:00
Adam 3210df7428 wip: desktop work 2025-10-29 16:04:34 -05:00
Adam cdeb82e9ca wip: desktop work 2025-10-29 16:04:34 -05:00
a9cae7b335 feat: add positional argument support to slash commands (#3456)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-29 14:54:24 -05:00
Frank 972c0893dd zen: fix unified endpoint for codex 2025-10-29 15:06:37 -04:00
opencode e5d89ca567 release: v0.15.28 2025-10-29 19:00:56 +00:00
Aiden ClineandGitHub 4ae70d4b0d fix: parcel watcher issue (#3544) 2025-10-29 13:52:38 -05:00
opencode 935cd7481b release: v0.15.27 2025-10-29 18:41:24 +00:00
Dax Raad 5553efea5e only generate user message summary if no diffs 2025-10-29 14:28:43 -04:00
Adamandopencode 0ff73ed8a6 wip: desktop work 2025-10-29 16:59:45 +00:00
opencode 5e792d7ac5 release: v0.15.26 2025-10-29 16:59:45 +00:00
David Hill 4a77e94e3c Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-10-29 16:16:56 +00:00
David Hill 4c563ea405 Enron update 2025-10-29 16:16:48 +00:00
Aiden Cline 5875257462 ignore: add label 2025-10-29 11:08:46 -05:00
David Hill 9701891e94 testimonial and other polish 2025-10-29 16:02:25 +00:00
opencode a2ab37c1b6 release: v0.15.25 2025-10-29 16:01:53 +00:00
Dax Raad 4d6e2d8efc autoupgrade latest major only 2025-10-29 11:53:25 -04:00
Aiden Cline 4407d5d96f fix: ensure tool inputs are zod validated 2025-10-29 10:46:57 -05:00
Adam 244945c0e7 fix: desktop error 2025-10-29 10:43:34 -05:00
opencode c652b2b4e8 release: v0.15.24 2025-10-29 15:38:55 +00:00
David Hill aabeeb1431 Adding polish 2025-10-29 15:34:45 +00:00
David Hill 0fbedc5e19 Fix form submission 2025-10-29 15:34:39 +00:00
Dax Raad 12782fff14 remove log 2025-10-29 11:27:51 -04:00
Dax Raad ca463a2346 session diff only include modified files 2025-10-29 11:26:21 -04:00
Aiden Cline 7265cdf817 ignore: rm 2025-10-29 10:19:02 -05:00
David Hill 7baa751351 First pass at adding an enterprise page 2025-10-29 15:16:17 +00:00
Adam 5b86fa9109 wip: desktop work 2025-10-29 07:32:01 -05:00
Adam aa7e008fe1 wip: desktop work 2025-10-29 07:32:00 -05:00
GitHub Action 792664071c ignore: update download stats 2025-10-29 2025-10-29 12:04:57 +00:00
Aiden Cline a0541ba57a zen: fix models endpoint to be openai compatible 2025-10-28 22:48:57 -05:00
Aiden Cline 4994bf1b46 ignore: rename type 2025-10-28 22:39:57 -05:00
Tyler GannonandGitHub 1e24514d61 add OpenAPI annotations to tui.ts control endpoints (#3519) 2025-10-28 22:39:22 -05:00
opencode 4b1c6300a0 release: v0.15.23 2025-10-29 01:35:27 +00:00
Dax Raad db3fb9d316 ci: stuff 2025-10-28 21:28:44 -04:00
Dax Raad cd79676b42 sync 2025-10-28 20:35:18 -04:00
Dax Raad 09e7e0ab70 tag majors 2025-10-28 20:28:30 -04:00
0e60f66604 ignore: python sdk (#2779)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-28 18:32:45 -05:00
Aiden Cline fc8db6cdf9 fix: ensure timeout param passed to bash tool is positive 2025-10-28 17:32:39 -05:00
kcrommettandGitHub 5cc37c4ea0 mcp: fix status() to not overwrite connected with failed (#3514) 2025-10-28 16:16:03 -05:00
Adam 46ad456718 wip: desktop work 2025-10-28 15:39:41 -05:00
Haris GušićandGitHub 832ffd2303 fix: Use process.stdout.write instead of console.log (#3508) 2025-10-28 15:38:08 -05:00
GitHub Action b261430880 chore: format code 2025-10-28 20:29:47 +00:00
Adam 545f345848 wip: desktop work 2025-10-28 15:29:16 -05:00
Adam 77ae0b527e wip: desktop work 2025-10-28 15:29:16 -05:00
Adam c1278109c9 wip: desktop work 2025-10-28 15:29:16 -05:00
Adam a7a88d01ef wip: desktop work 2025-10-28 15:29:16 -05:00
Adam 4e0ab6b634 wip: desktop work 2025-10-28 15:29:16 -05:00
Adam d36485b7af wip: desktop work 2025-10-28 15:29:15 -05:00
Adam 1da24f6adb wip: desktop work 2025-10-28 15:29:15 -05:00
Frank e29dd27632 zen: provider affinity 2025-10-28 15:55:03 -04:00
Aiden ClineandGitHub 37380e1f94 add --title flag to opencode run (#3507) 2025-10-28 13:32:36 -05:00
Dax Raad 1309ca7a81 ignore 2025-10-28 14:13:47 -04:00
Dax Raad c1515316f5 core: fix additions and deletions counting in edit tool filediff 2025-10-28 14:08:10 -04:00
b66e7b6fce tweak: add experimental chatMaxRetries to config (#2116)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-28 12:09:41 -05:00
oribiGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
eb398f1951 add OPENCODE_CONFIG_DIR to allow loading a custom config directory (#3504)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-28 11:50:09 -05:00
Aiden Cline 643c22d21f add catch for mcp tool execution 2025-10-28 10:23:04 -05:00
Aiden Cline 74acd08ead add catch for mcp tool execution 2025-10-28 10:23:03 -05:00
opencode 49ea5aa2ad release: v0.15.20 2025-10-28 15:12:37 +00:00
Aiden Cline ee1af0fe80 fix: blank version issue 2025-10-28 10:03:53 -05:00
GitHub Action dfebf40471 ignore: update download stats 2025-10-28 2025-10-28 12:04:31 +00:00
opencode 6af6a1295f release: v0.15.19 2025-10-28 08:12:32 +00:00
Dax Raad 22821744ef feat: add OPENCODE_FAKE_VCS flag for VCS testing and update todo tracking instructions 2025-10-28 02:54:29 -04:00
Aiden Cline 872c9467b2 chore: rm unused import 2025-10-28 00:43:29 -05:00
Dax Raad d8249f32a8 do not set temperature for claude models 2025-10-28 01:14:25 -04:00
Aiden ClineandGitHub 982954cc1b feat (acp): mcp server support, file diffs, some default slash commands (/init, /compact), show todos properly (#3490)
The mcp server support does not mean acp didn't allow u to use mcp servers previously, it means that now you can connect new servers via ACP instead of relying on the opencode defined ones
2025-10-28 00:08:30 -05:00
Frank 4caa458232 acp: fix type error 2025-10-27 21:40:08 -04:00
Frank 6fe8e3973c zen: support 1M claude context 2025-10-27 21:36:10 -04:00
Frank 7816901713 wip: zen doc 2025-10-27 21:25:56 -04:00
Frank 71abca9571 wip: zen 2025-10-27 19:26:28 -04:00
kcrommettandGitHub 7216a8c86d fix: editor paste functionality for text attachments (#3489) 2025-10-27 17:51:33 -05:00
Jay V e3e16e58c5 docs: edit 2025-10-27 18:16:48 -04:00
Dax Raad a2951a2702 Remove typecheck script from desktop package 2025-10-27 18:03:32 -04:00
Jay V 55453dc606 Add missing dependencies for desktop package 2025-10-27 17:49:31 -04:00
Jay V 198d7f7e5f Merge branch 'doc-acp' into dev 2025-10-27 17:49:24 -04:00
Jay V e3e9fd7aa8 docs: edit 2025-10-27 17:48:17 -04:00
Aiden Cline 3c56dbcf58 chore: rm comment 2025-10-27 16:15:13 -05:00
Aiden Cline ee07ed2dc4 chore: delete unused file 2025-10-27 15:44:12 -05:00
Adam 485e4520e7 wip: desktop work 2025-10-27 15:37:07 -05:00
Adam fc115ea367 wip: desktop work 2025-10-27 15:37:07 -05:00
Adam d03b79e61e wip: desktop work 2025-10-27 15:37:06 -05:00
Adam 0acae8211a wip: desktop work 2025-10-27 15:37:06 -05:00
Aiden Cline 0af4505756 fix: litellm error tool= param must be specified 2025-10-27 14:03:42 -05:00
Aurelien RibonandGitHub a606e1d2ec fix: dont set reasoning effort to medium for gpt-5-pro (#3474) 2025-10-27 10:50:57 -05:00
Aiden Cline 0e65700183 update sdk 2025-10-27 10:47:04 -05:00
Aiden Cline e6301ca5d5 tweak: rename event 2025-10-27 10:42:47 -05:00
Bernat PericàsandGitHub b562863fcc feat: add session.started event that triggers when a new session is created (#3413) 2025-10-27 07:18:23 -05:00
GitHub Action db85f01eff ignore: update download stats 2025-10-27 2025-10-27 12:04:50 +00:00
Aiden Cline 1a6fd018f6 Revert "fix: Explicitly exit CLI to prevent hanging subprocesses (#3083)"
This reverts commit a9624c0fff.
2025-10-27 01:30:13 -05:00
Aiden Cline fdb5bae3c6 docs: acp 2025-10-27 00:56:00 -05:00
a9624c0fff fix: Explicitly exit CLI to prevent hanging subprocesses (#3083)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-10-27 00:03:10 -05:00
Aiden Cline 316d4c9197 wip 2025-10-26 23:49:56 -05:00
Denys RybalkaandGitHub 5e886c35d5 chore: use stable URLs in PKGBUILD (#3448) 2025-10-26 19:50:45 -05:00
Aiden Cline 5162268f9d docs: update agent frontmatter permission example 2025-10-26 15:04:48 -05:00
Jérôme BenoitGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
0eb899a950 chore: cleanup versioned zod imports (#3460)
Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-26 14:50:41 -05:00
Dan McGuirkandGitHub 3241f6b8bb docs: fix typos (#3454) 2025-10-26 14:37:25 -05:00
GitHub Action 2c792f17e6 ignore: update download stats 2025-10-26 2025-10-26 12:04:22 +00:00
7d0c6860cd fix: make build script work cross-platform (#3430)
Co-authored-by: JosXa <info@josxa.dev>
2025-10-26 01:40:17 -05:00
Aiden Cline c70e393c81 Remove claude-haiku-4.5 from default priority for GitHub Copilot session title generation 2025-10-26 01:21:34 -05:00
opencode 20963c4186 release: v0.15.18 2025-10-26 03:49:21 +00:00
Aiden Cline 0a778a2789 make title gen more reliable 2025-10-25 22:14:29 -05:00
Aiden ClineandGitHub 42c1e61bf4 fix: $ invocation not .quiet() (#3449) 2025-10-25 16:27:09 -05:00
Dax Raad 795b845782 update anthropic prompt 2025-10-25 17:26:27 -04:00
Mohammad AlhashemiGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
2e434a459a feat: add noReply parameter (#3433)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-10-25 14:56:54 -05:00
Paulo Edgar CastroandGitHub ae62bc8b1f fix: timeout param that allows user to disable provider timeout (#3443) 2025-10-25 12:08:27 -05:00
GitHub Action 187a5fe301 ignore: update download stats 2025-10-25 2025-10-25 12:04:18 +00:00
474 changed files with 32690 additions and 2557 deletions
+71
View File
@@ -0,0 +1,71 @@
#
# This file is intentionally in the wrong dir, will move and add later....
#
# name: publish-python-sdk
# on:
# release:
# types: [published]
# workflow_dispatch:
# jobs:
# publish:
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
# - name: Setup Bun
# uses: oven-sh/setup-bun@v1
# with:
# bun-version: 1.2.21
# - name: Install dependencies (JS/Bun)
# run: bun install
# - name: Install uv
# shell: bash
# run: curl -LsSf https://astral.sh/uv/install.sh | sh
# - name: Generate Python SDK from OpenAPI (CLI)
# shell: bash
# run: |
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/generate.py --source cli
# - name: Sync Python dependencies
# shell: bash
# run: |
# ~/.local/bin/uv sync --dev --project packages/sdk/python
# - name: Set version from release tag
# shell: bash
# run: |
# TAG="${GITHUB_REF_NAME:-}"
# if [ -z "$TAG" ]; then
# TAG="$(git describe --tags --abbrev=0 || echo 0.0.0)"
# fi
# echo "Using version: $TAG"
# VERSION="$TAG" ~/.local/bin/uv run --project packages/sdk/python python - <<'PY'
# import os, re, pathlib
# root = pathlib.Path('packages/sdk/python')
# pt = (root / 'pyproject.toml').read_text()
# version = os.environ.get('VERSION','0.0.0').lstrip('v')
# pt = re.sub(r'(?m)^(version\s*=\s*")[^"]+("\s*)$', f"\\1{version}\\2", pt)
# (root / 'pyproject.toml').write_text(pt)
# # Also update generator config override for consistency
# cfgp = root / 'openapi-python-client.yaml'
# if cfgp.exists():
# cfg = cfgp.read_text()
# cfg = re.sub(r'(?m)^(package_version_override:\s*)\S+$', f"\\1{version}", cfg)
# cfgp.write_text(cfg)
# PY
# - name: Build and publish to PyPI
# env:
# PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
# shell: bash
# run: |
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/publish.py
+5 -1
View File
@@ -53,13 +53,17 @@ jobs:
- name: Install OpenCode
run: curl -fsSL https://opencode.ai/install | bash
- name: Setup npm auth
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Publish
run: |
./script/publish.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_CHANNEL: latest
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+1
View File
@@ -5,6 +5,7 @@ on:
branches:
- dev
- opentui
- v0
concurrency: ${{ github.workflow }}-${{ github.ref }}
+1
View File
@@ -17,6 +17,7 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
- [`help wanted`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Ahelp-wanted)
- [`good first issue`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22good%20first%20issue%22)
- [`bug`](https://github.com/sst/opencode/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug)
- [`perf`](https://github.com/sst/opencode/issues?q=is%3Aopen%20is%3Aissue%20label%3A%22perf%22)
> [!NOTE]
> PRs that ignore these guardrails will likely be closed.
+7
View File
@@ -118,3 +118,10 @@
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
+34 -22
View File
@@ -37,7 +37,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -64,7 +64,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -88,7 +88,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -109,12 +109,11 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
@@ -149,7 +148,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -165,7 +164,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.15.17",
"version": "0.15.30",
"bin": {
"opencode": "./bin/opencode",
},
@@ -216,6 +215,10 @@
"@ai-sdk/google-vertex": "3.0.16",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
@@ -229,7 +232,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -249,7 +252,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.15.17",
"version": "0.15.30",
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
@@ -260,7 +263,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -273,20 +276,27 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@opencode-ai/sdk": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"remeda": "catalog:",
"shiki": "3.9.2",
"solid-js": "catalog:",
"solid-list": "catalog:",
"virtua": "catalog:",
},
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
@@ -296,7 +306,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -343,7 +353,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@pierre/precision-diffs": "0.4.1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
@@ -937,7 +947,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -1075,7 +1085,7 @@
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA=="],
@@ -3513,6 +3523,8 @@
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
"@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
@@ -3525,10 +3537,6 @@
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
@@ -3793,8 +3801,6 @@
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
"shiki/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="],
"sitemap/sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -3949,6 +3955,8 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
@@ -4083,6 +4091,8 @@
"@opencode-ai/web/shiki/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
@@ -4345,6 +4355,8 @@
"@actions/github/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@astrojs/mdx/@astrojs/markdown-remark/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
+2 -2
View File
@@ -104,7 +104,7 @@ To test locally:
- `MODEL`: The model used by opencode. Same as the `MODEL` defined in the GitHub workflow.
- `ANTHROPIC_API_KEY`: Your model provider API key. Same as the keys defined in the GitHub workflow.
- `GITHUB_RUN_ID`: Dummy value to emulate GitHub action environment.
- `MOCK_TOKEN`: A GitHub persontal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
- `MOCK_TOKEN`: A GitHub personal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
- `MOCK_EVENT`: Mock GitHub event payload (see templates below).
- `/path/to/opencode`: Path to your cloned opencode repo. `bun /path/to/opencode/github/index.ts` runs your local version of `opencode`.
@@ -118,7 +118,7 @@ Replace:
- `"owner":"sst"` with repo owner
- `"repo":"hello-world"` with repo name
- `"actor":"fwang"` with the GitHub username of commentor
- `"actor":"fwang"` with the GitHub username of commenter
- `"number":4` with the GitHub issue id
- `"body":"hey opencode, summarize thread"` with comment body
+4 -4
View File
@@ -25,7 +25,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
"@pierre/precision-diffs": "0.4.1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
@@ -53,8 +53,8 @@
"turbo": "2.5.6"
},
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/script": "workspace:*"
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*"
},
"repository": {
"type": "git",
@@ -63,7 +63,7 @@
"license": "MIT",
"prettier": {
"semi": false,
"printWidth": 120
"printWidth": 100
},
"trustedDependencies": [
"esbuild",
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.15.17"
"version": "0.15.30"
},
"dependencies": {
"@ibm/plex": "6.4.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,16 @@
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86283)">
<mask id="mask0_1401_86283" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86283)">
<path d="M180 240H60V120H180V240Z" fill="#4B4646"/>
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#F1ECEC"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86283">
<rect width="240" height="300" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,16 @@
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86274)">
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86274)">
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86274">
<rect width="240" height="300" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1,30 @@
<svg width="641" height="115" viewBox="0 0 641 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86292)">
<mask id="mask0_1401_86292" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="641" height="115">
<path d="M640.714 0H0V115H640.714V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86292)">
<path d="M49.2868 82.1433H16.4297V49.2861H49.2868V82.1433Z" fill="#4B4646"/>
<path d="M49.2857 32.8573H16.4286V82.143H49.2857V32.8573ZM65.7143 98.5716H0V16.4287H65.7143V98.5716Z" fill="#B7B1B1"/>
<path d="M131.427 82.1433H98.5703V49.2861H131.427V82.1433Z" fill="#4B4646"/>
<path d="M98.5692 82.143H131.426V32.8573H98.5692V82.143ZM147.855 98.5716H98.5692V115H82.1406V16.4287H147.855V98.5716Z" fill="#B7B1B1"/>
<path d="M229.997 65.7139V82.1424H180.711V65.7139H229.997Z" fill="#4B4646"/>
<path d="M230.003 65.7144H180.718V82.143H230.003V98.5716H164.289V16.4287H230.003V65.7144ZM180.718 49.2859H213.575V32.8573H180.718V49.2859Z" fill="#B7B1B1"/>
<path d="M295.717 98.5718H262.859V49.2861H295.717V98.5718Z" fill="#4B4646"/>
<path d="M295.715 32.8573H262.858V98.5716H246.43V16.4287H295.715V32.8573ZM312.144 98.5716H295.715V32.8573H312.144V98.5716Z" fill="#B7B1B1"/>
<path d="M394.286 82.1433H345V49.2861H394.286V82.1433Z" fill="#4B4646"/>
<path d="M394.285 32.8573H344.999V82.143H394.285V98.5716H328.57V16.4287H394.285V32.8573Z" fill="#F1ECEC"/>
<path d="M459.998 82.1433H427.141V49.2861H459.998V82.1433Z" fill="#4B4646"/>
<path d="M459.997 32.8573H427.14V82.143H459.997V32.8573ZM476.425 98.5716H410.711V16.4287H476.425V98.5716Z" fill="#F1ECEC"/>
<path d="M542.146 82.1433H509.289V49.2861H542.146V82.1433Z" fill="#4B4646"/>
<path d="M542.145 32.8571H509.288V82.1429H542.145V32.8571ZM558.574 98.5714H492.859V16.4286H542.145V0H558.574V98.5714Z" fill="#F1ECEC"/>
<path d="M640.715 65.7139V82.1424H591.43V65.7139H640.715Z" fill="#4B4646"/>
<path d="M591.429 32.8573V49.2859H624.286V32.8573H591.429ZM640.714 65.7144H591.429V82.143H640.714V98.5716H575V16.4287H640.714V65.7144Z" fill="#F1ECEC"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86292">
<rect width="640.714" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,30 @@
<svg width="640" height="115" viewBox="0 0 640 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86330)">
<mask id="mask0_1401_86330" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="640" height="115">
<path d="M640 0H0V115H640V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86330)">
<path d="M49.2346 82.1433H16.4141V49.2861H49.2346V82.1433Z" fill="#CFCECD"/>
<path d="M49.2308 32.8573H16.4103V82.143H49.2308V32.8573ZM65.641 98.5716H0V16.4287H65.641V98.5716Z" fill="#656363"/>
<path d="M131.281 82.1433H98.4609V49.2861H131.281V82.1433Z" fill="#CFCECD"/>
<path d="M98.4649 82.143H131.285V32.8573H98.4649V82.143ZM147.696 98.5716H98.4649V115H82.0547V16.4287H147.696V98.5716Z" fill="#656363"/>
<path d="M229.746 65.7139V82.1424H180.516V65.7139H229.746Z" fill="#CFCECD"/>
<path d="M229.743 65.7144H180.512V82.143H229.743V98.5716H164.102V16.4287H229.743V65.7144ZM180.512 49.2859H213.332V32.8573H180.512V49.2859Z" fill="#656363"/>
<path d="M295.383 98.5718H262.562V49.2861H295.383V98.5718Z" fill="#CFCECD"/>
<path d="M295.387 32.8573H262.567V98.5716H246.156V16.4287H295.387V32.8573ZM311.797 98.5716H295.387V32.8573H311.797V98.5716Z" fill="#656363"/>
<path d="M393.848 82.1433H344.617V49.2861H393.848V82.1433Z" fill="#CFCECD"/>
<path d="M393.844 32.8573H344.613V82.143H393.844V98.5716H328.203V16.4287H393.844V32.8573Z" fill="#211E1E"/>
<path d="M459.485 82.1433H426.664V49.2861H459.485V82.1433Z" fill="#CFCECD"/>
<path d="M459.489 32.8573H426.668V82.143H459.489V32.8573ZM475.899 98.5716H410.258V16.4287H475.899V98.5716Z" fill="#211E1E"/>
<path d="M541.539 82.1433H508.719V49.2861H541.539V82.1433Z" fill="#CFCECD"/>
<path d="M541.535 32.8571H508.715V82.1428H541.535V32.8571ZM557.946 98.5714H492.305V16.4286H541.535V0H557.946V98.5714Z" fill="#211E1E"/>
<path d="M639.996 65.7139V82.1424H590.766V65.7139H639.996Z" fill="#CFCECD"/>
<path d="M590.77 32.8573V49.2859H623.59V32.8573H590.77ZM640 65.7144H590.77V82.143H640V98.5716H574.359V16.4287H640V65.7144Z" fill="#211E1E"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86330">
<rect width="640" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,22 @@
<svg width="641" height="115" viewBox="0 0 641 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86315)">
<mask id="mask0_1401_86315" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="641" height="115">
<path d="M640.714 0H0V115H640.714V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86315)">
<path d="M49.2857 32.8573H16.4286V82.143H49.2857V32.8573ZM65.7143 98.5716H0V16.4287H65.7143V98.5716Z" fill="white"/>
<path d="M98.5692 82.143H131.426V32.8573H98.5692V82.143ZM147.855 98.5716H98.5692V115H82.1406V16.4287H147.855V98.5716Z" fill="white"/>
<path d="M230.003 65.7144H180.718V82.143H230.003V98.5716H164.289V16.4287H230.003V65.7144ZM180.718 49.2859H213.575V32.8573H180.718V49.2859Z" fill="white"/>
<path d="M295.715 32.8573H262.858V98.5716H246.43V16.4287H295.715V32.8573ZM312.144 98.5716H295.715V32.8573H312.144V98.5716Z" fill="white"/>
<path d="M394.285 32.8573H344.999V82.143H394.285V98.5716H328.57V16.4287H394.285V32.8573Z" fill="white"/>
<path d="M459.997 32.8573H427.14V82.143H459.997V32.8573ZM476.425 98.5716H410.711V16.4287H476.425V98.5716Z" fill="white"/>
<path d="M542.145 32.8571H509.288V82.1429H542.145V32.8571ZM558.574 98.5714H492.859V16.4286H542.145V0H558.574V98.5714Z" fill="white"/>
<path d="M591.429 32.8573V49.2859H624.286V32.8573H591.429ZM640.714 65.7144H591.429V82.143H640.714V98.5716H575V16.4287H640.714V65.7144Z" fill="white"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86315">
<rect width="640.714" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,22 @@
<svg width="640" height="115" viewBox="0 0 640 115" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86353)">
<mask id="mask0_1401_86353" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="640" height="115">
<path d="M640 0H0V115H640V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86353)">
<path d="M49.2308 32.8573H16.4103V82.143H49.2308V32.8573ZM65.641 98.5716H0V16.4287H65.641V98.5716Z" fill="black"/>
<path d="M98.4649 82.143H131.285V32.8573H98.4649V82.143ZM147.696 98.5716H98.4649V115H82.0547V16.4287H147.696V98.5716Z" fill="black"/>
<path d="M229.743 65.7144H180.512V82.143H229.743V98.5716H164.102V16.4287H229.743V65.7144ZM180.512 49.2859H213.332V32.8573H180.512V49.2859Z" fill="black"/>
<path d="M295.387 32.8573H262.567V98.5716H246.156V16.4287H295.387V32.8573ZM311.797 98.5716H295.387V32.8573H311.797V98.5716Z" fill="black"/>
<path d="M393.844 32.8573H344.613V82.143H393.844V98.5716H328.203V16.4287H393.844V32.8573Z" fill="black"/>
<path d="M459.489 32.8573H426.668V82.143H459.489V32.8573ZM475.899 98.5716H410.258V16.4287H475.899V98.5716Z" fill="black"/>
<path d="M541.535 32.8571H508.715V82.1428H541.535V32.8571ZM557.946 98.5714H492.305V16.4286H541.535V0H557.946V98.5714Z" fill="black"/>
<path d="M590.77 32.8573V49.2859H623.59V32.8573H590.77ZM640 65.7144H590.77V82.143H640V98.5716H574.359V16.4287H640V65.7144Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86353">
<rect width="640" height="115" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,10 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="url(#paint0_linear_1311_94922)" stroke="#F1ECEC"/>
<path d="M3 0V26M19 0V26M15 0V26M7 0V26M0 3H22M0 7H22M0 19H22M0 23H22" stroke="#4B4646" stroke-opacity="0.4"/>
<defs>
<linearGradient id="paint0_linear_1311_94922" x1="11" y1="3" x2="11" y2="23" gradientUnits="userSpaceOnUse">
<stop stop-color="#1B1818"/>
<stop offset="1" stop-color="#2D2828"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 526 B

@@ -0,0 +1,10 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="url(#paint0_linear_1311_94913)" stroke="#8E8B8B"/>
<path d="M3 0V26M19 0V26M15 0V26M7 0V26M0 3H22M0 7H22M0 19H22M0 23H22" stroke="#110000" stroke-opacity="0.121569"/>
<defs>
<linearGradient id="paint0_linear_1311_94913" x1="11" y1="3" x2="11" y2="23" gradientUnits="userSpaceOnUse">
<stop stop-color="#F9F8F8"/>
<stop offset="1" stop-color="#E9E8E8"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,11 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_94916)">
<path d="M15 19H7V11H15V19Z" fill="#4B4646"/>
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="#F1ECEC"/>
</g>
<defs>
<clipPath id="clip0_1311_94916">
<rect width="16" height="20" fill="white" transform="translate(3 3)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,11 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_94907)">
<path d="M15 19H7V11H15V19Z" fill="#BCBBBB"/>
<path d="M15 7H7V19H15V7ZM19 23H3V3H19V23Z" fill="#211E1E"/>
</g>
<defs>
<clipPath id="clip0_1311_94907">
<rect width="16" height="20" fill="white" transform="translate(3 3)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,11 @@
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_94973)">
<path d="M24 32H8V16H24V32Z" fill="#4B4646"/>
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#F1ECEC"/>
</g>
<defs>
<clipPath id="clip0_1311_94973">
<rect width="32" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,11 @@
<svg width="32" height="40" viewBox="0 0 32 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_94969)">
<path d="M24 32H8V16H24V32Z" fill="#BCBBBB"/>
<path d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" fill="#211E1E"/>
</g>
<defs>
<clipPath id="clip0_1311_94969">
<rect width="32" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,25 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_95032)">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</g>
<defs>
<clipPath id="clip0_1311_95032">
<rect width="234" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,25 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1311_95049)">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</g>
<defs>
<clipPath id="clip0_1311_95049">
<rect width="234" height="42" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.33203 7.99967V6.33301H10.9987M17.6654 7.99967V6.33301H10.9987M10.9987 6.33301V19.6663M10.9987 19.6663H9.33203M10.9987 19.6663H12.6654" stroke="#F1ECEC" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

@@ -0,0 +1,3 @@
<svg width="22" height="26" viewBox="0 0 22 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.33203 7.99967V6.33301H10.9987M17.6654 7.99967V6.33301H10.9987M10.9987 6.33301V19.6663M10.9987 19.6663H9.33203M10.9987 19.6663H12.6654" stroke="black" stroke-width="2" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 308 B

@@ -0,0 +1,63 @@
.context-menu {
position: fixed;
z-index: 1000;
min-width: 160px;
border-radius: 8px;
background-color: var(--color-background);
box-shadow:
0 0 0 1px rgba(19, 16, 16, 0.08),
0 6px 8px -4px rgba(19, 16, 16, 0.12),
0 4px 3px -2px rgba(19, 16, 16, 0.12),
0 1px 2px -1px rgba(19, 16, 16, 0.12);
padding: 6px;
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 0 1px rgba(247, 237, 237, 0.1);
}
}
.context-menu-item {
display: flex;
gap: 12px;
width: 100%;
padding: 8px 16px 8px 8px;
font-weight: 500;
cursor: pointer;
background: none;
border: none;
align-items: center;
color: var(--color-text);
font-size: var(--font-size-sm);
text-align: left;
border-radius: 2px;
transition: background-color 0.2s ease;
[data-slot="copy dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="copy light"] {
display: none;
}
[data-slot="copy dark"] {
display: block;
}
}
&:hover {
background-color: var(--color-background-weak-hover);
color: var(--color-text-strong);
}
img {
width: 22px;
height: 26px;
}
}
.context-menu-divider {
border: none;
border-top: 1px solid var(--color-border);
margin: var(--space-1) 0;
}
+131 -6
View File
@@ -1,11 +1,40 @@
import logoLight from "../asset/logo-ornate-light.svg"
import logoDark from "../asset/logo-ornate-dark.svg"
import { A, createAsync } from "@solidjs/router"
import copyLogoLight from "../asset/lander/logo-light.svg"
import copyLogoDark from "../asset/lander/logo-dark.svg"
import copyWordmarkLight from "../asset/lander/wordmark-light.svg"
import copyWordmarkDark from "../asset/lander/wordmark-dark.svg"
import copyBrandAssetsLight from "../asset/lander/brand-assets-light.svg"
import copyBrandAssetsDark from "../asset/lander/brand-assets-dark.svg"
// SVG files for copying (separate from button icons)
// Replace these with your actual SVG files for copying
import copyLogoSvgLight from "../asset/lander/opencode-logo-light.svg"
import copyLogoSvgDark from "../asset/lander/opencode-logo-dark.svg"
import copyWordmarkSvgLight from "../asset/lander/opencode-wordmark-light.svg"
import copyWordmarkSvgDark from "../asset/lander/opencode-wordmark-dark.svg"
import { A, createAsync, useNavigate } from "@solidjs/router"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import "./header-context-menu.css"
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
const fetchSvgContent = async (svgPath: string): Promise<string> => {
try {
const response = await fetch(svgPath)
const svgText = await response.text()
return svgText
} catch (err) {
console.error("Failed to fetch SVG content:", err)
throw err
}
}
export function Header(props: { zen?: boolean }) {
const navigate = useNavigate()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -13,19 +42,109 @@ export function Header(props: { zen?: boolean }) {
notation: "compact",
compactDisplay: "short",
}).format(githubData()?.stars!)
: "25K",
: "29K",
)
const [store, setStore] = createStore({
mobileMenuOpen: false,
contextMenuOpen: false,
contextMenuPosition: { x: 0, y: 0 },
})
createEffect(() => {
const handleClickOutside = () => {
setStore("contextMenuOpen", false)
}
const handleContextMenu = (event: MouseEvent) => {
event.preventDefault()
setStore("contextMenuOpen", false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setStore("contextMenuOpen", false)
}
}
if (store.contextMenuOpen) {
document.addEventListener("click", handleClickOutside)
document.addEventListener("contextmenu", handleContextMenu)
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
document.removeEventListener("click", handleClickOutside)
document.removeEventListener("contextmenu", handleContextMenu)
document.removeEventListener("keydown", handleKeyDown)
})
}
})
const handleLogoContextMenu = (event: MouseEvent) => {
event.preventDefault()
const logoElement = (event.currentTarget as HTMLElement).querySelector("a")
if (logoElement) {
const rect = logoElement.getBoundingClientRect()
setStore("contextMenuPosition", {
x: rect.left - 16,
y: rect.bottom + 8,
})
}
setStore("contextMenuOpen", true)
}
const copyWordmarkToClipboard = async () => {
try {
const isDark = isDarkMode()
const wordmarkSvgPath = isDark ? copyWordmarkSvgDark : copyWordmarkSvgLight
const wordmarkSvg = await fetchSvgContent(wordmarkSvgPath)
await navigator.clipboard.writeText(wordmarkSvg)
} catch (err) {
console.error("Failed to copy wordmark to clipboard:", err)
}
}
const copyLogoToClipboard = async () => {
try {
const isDark = isDarkMode()
const logoSvgPath = isDark ? copyLogoSvgDark : copyLogoSvgLight
const logoSvg = await fetchSvgContent(logoSvgPath)
await navigator.clipboard.writeText(logoSvg)
} catch (err) {
console.error("Failed to copy logo to clipboard:", err)
}
}
return (
<section data-component="top">
<A href="/">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
</A>
<div onContextMenu={handleLogoContextMenu}>
<A href="/">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
</A>
</div>
<Show when={store.contextMenuOpen}>
<div
class="context-menu"
style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`}
>
<button class="context-menu-item" onClick={copyLogoToClipboard}>
<img data-slot="copy light" src={copyLogoLight} alt="Logo" />
<img data-slot="copy dark" src={copyLogoDark} alt="Logo" />
Copy logo as SVG
</button>
<button class="context-menu-item" onClick={copyWordmarkToClipboard}>
<img data-slot="copy light" src={copyWordmarkLight} alt="Wordmark" />
<img data-slot="copy dark" src={copyWordmarkDark} alt="Wordmark" />
Copy wordmark as SVG
</button>
<button class="context-menu-item" onClick={() => navigate("/brand")}>
<img data-slot="copy light" src={copyBrandAssetsLight} alt="Brand Assets" />
<img data-slot="copy dark" src={copyBrandAssetsDark} alt="Brand Assets" />
Brand assets
</button>
</div>
</Show>
<nav data-component="nav-desktop">
<ul>
<li>
@@ -36,6 +155,9 @@ export function Header(props: { zen?: boolean }) {
<li>
<a href="/docs">Docs</a>
</li>
<li>
<A href="/enterprise">Enterprise</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
@@ -107,6 +229,9 @@ export function Header(props: { zen?: boolean }) {
<li>
<a href="/docs">Docs</a>
</li>
<li>
<A href="/enterprise">Enterprise</A>
</li>
<li>
<Switch>
<Match when={props.zen}>
@@ -1,9 +1,14 @@
import { A } from "@solidjs/router"
export function Legal() {
return (
<div data-component="legal">
<span>
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<span>
<A href="/brand">Brand</A>
</span>
</div>
)
}
@@ -0,0 +1,46 @@
import type { APIEvent } from "@solidjs/start/server"
import { AWS } from "@opencode-ai/console-core/aws.js"
interface EnterpriseFormData {
name: string
role: string
email: string
message: string
}
export async function POST(event: APIEvent) {
try {
const body = (await event.request.json()) as EnterpriseFormData
// Validate required fields
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: "All fields are required" }, { status: 400 })
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: "Invalid email format" }, { status: 400 })
}
// Create email content
const emailContent = `
${body.message}<br><br>
--<br>
${body.name}<br>
${body.role}<br>
${body.email}`.trim()
// Send email using AWS SES
await AWS.sendEmail({
to: "contact@anoma.ly",
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
})
return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })
} catch (error) {
console.error("Error processing enterprise form:", error)
return Response.json({ error: "Internal server error" }, { status: 500 })
}
}
@@ -0,0 +1,529 @@
::selection {
background: var(--color-background-interactive);
color: var(--color-text-strong);
@media (prefers-color-scheme: dark) {
background: var(--color-background-interactive);
color: var(--color-text-inverted);
}
}
[data-page="enterprise"] {
--color-background: hsl(0, 20%, 99%);
--color-background-weak: hsl(0, 8%, 97%);
--color-background-weak-hover: hsl(0, 8%, 94%);
--color-background-strong: hsl(0, 5%, 12%);
--color-background-strong-hover: hsl(0, 5%, 18%);
--color-background-interactive: hsl(62, 84%, 88%);
--color-background-interactive-weaker: hsl(64, 74%, 95%);
--color-text: hsl(0, 1%, 39%);
--color-text-weak: hsl(0, 1%, 60%);
--color-text-weaker: hsl(30, 2%, 81%);
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-text-success: hsl(119, 100%, 35%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(0, 1%, 55%);
--color-success: hsl(142, 76%, 36%);
background: var(--color-background);
font-family: var(--font-mono);
color: var(--color-text);
padding-bottom: 5rem;
@media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%);
--color-background-weak: hsl(0, 6%, 10%);
--color-background-weak-hover: hsl(0, 6%, 15%);
--color-background-strong: hsl(0, 15%, 94%);
--color-background-strong-hover: hsl(0, 15%, 97%);
--color-background-interactive: hsl(62, 100%, 90%);
--color-background-interactive-weaker: hsl(60, 20%, 8%);
--color-text: hsl(0, 4%, 71%);
--color-text-weak: hsl(0, 2%, 49%);
--color-text-weaker: hsl(0, 3%, 28%);
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-text-success: hsl(119, 60%, 72%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
--color-icon: hsl(10, 3%, 43%);
--color-success: hsl(142, 76%, 46%);
}
/* Header and Footer styles - copied from index.css */
[data-component="top"] {
padding: 24px 5rem;
height: 80px;
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-background);
border-bottom: 1px solid var(--color-border-weak);
z-index: 10;
@media (max-width: 60rem) {
padding: 24px 1.5rem;
}
img {
height: 34px;
width: auto;
}
[data-component="nav-desktop"] {
ul {
display: flex;
justify-content: space-between;
gap: 48px;
li {
display: inline-block;
a {
text-decoration: none;
span {
color: var(--color-text-weak);
}
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
@media (max-width: 40rem) {
display: none;
}
}
[data-component="nav-mobile"] {
button > svg {
color: var(--color-icon);
}
}
[data-component="nav-mobile-toggle"] {
border: none;
background: none;
outline: none;
height: 40px;
width: 40px;
cursor: pointer;
margin-right: -8px;
}
[data-component="nav-mobile-toggle"]:hover {
background: var(--color-background-weak);
}
[data-component="nav-mobile"] {
display: none;
@media (max-width: 40rem) {
display: block;
[data-component="nav-mobile-icon"] {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
[data-component="nav-mobile-menu-list"] {
position: fixed;
background: var(--color-background);
top: 80px;
left: 0;
right: 0;
height: 100vh;
ul {
list-style: none;
padding: 20px 0;
li {
a {
text-decoration: none;
padding: 20px;
display: block;
span {
color: var(--color-text-weak);
}
}
a:hover {
background: var(--color-background-weak);
}
}
}
}
}
}
[data-slot="logo dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="logo light"] {
display: none;
}
[data-slot="logo dark"] {
display: block;
}
}
}
[data-component="footer"] {
border-top: 1px solid var(--color-border-weak);
display: flex;
flex-direction: row;
@media (max-width: 65rem) {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
width: 100%;
display: block;
span {
color: var(--color-text-weak);
@media (max-width: 40rem) {
display: none;
}
}
}
a:hover {
background: var(--color-background-weak);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
[data-slot="cell"] + [data-slot="cell"] {
border-left: 1px solid var(--color-border-weak);
@media (max-width: 40rem) {
border-left: none;
}
}
/* Mobile: third column on its own row */
@media (max-width: 25rem) {
flex-wrap: wrap;
[data-slot="cell"] {
flex: 1 0 100%;
border-left: none;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="cell"]:nth-child(1) {
border-top: none;
}
}
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
border: 1px solid var(--color-border-weak);
border-top: none;
@media (max-width: 65rem) {
border: none;
}
}
[data-component="content"] {
}
[data-component="brand-content"] {
padding: 4rem 5rem;
h2 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
margin-bottom: 1rem;
}
h3 {
font-size: 1.25rem;
font-weight: 500;
color: var(--color-text-strong);
margin: 2rem 0 1rem 0;
}
p {
line-height: 1.6;
margin-bottom: 2.5rem;
color: var(--color-text);
}
[data-component="download-button"] {
padding: 8px 12px 8px 20px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
display: flex;
width: fit-content;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
text-decoration: none;
&:hover:not(:disabled) {
background: var(--color-background-strong-hover);
}
&:active {
transform: scale(0.98);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
[data-component="brand-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-top: 4rem;
margin-bottom: 2rem;
}
[data-component="brand-grid"] img {
width: 100%;
height: auto;
display: block;
border-radius: 4px;
border: 1px solid var(--color-border-weak);
}
[data-component="brand-grid"] > div {
position: relative;
}
[data-component="actions"] {
position: absolute;
background: rgba(4, 0, 0, 0.08);
border-radius: 4px;
bottom: 0;
right: 0;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
opacity: 0;
transition: opacity 0.2s ease;
@media (max-width: 40rem) {
position: static;
opacity: 1;
background: none;
margin-top: 1rem;
justify-content: start;
}
}
[data-component="brand-grid"] > div:hover [data-component="actions"] {
opacity: 1;
@media (max-width: 40rem) {
opacity: 1;
}
}
[data-component="actions"] button {
padding: 6px 12px;
background: var(--color-background);
color: var(--color-text);
border: none;
border-radius: 4px;
font-weight: 500;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
cursor: pointer;
box-shadow:
0 0 0 1px rgba(19, 16, 16, 0.08),
0 6px 8px -4px rgba(19, 16, 16, 0.12),
0 4px 3px -2px rgba(19, 16, 16, 0.12),
0 1px 2px -1px rgba(19, 16, 16, 0.12);
@media (max-width: 40rem) {
box-shadow:
0 0 0 1px rgba(19, 16, 16, 0.16)
}
&:hover {
background: var(--color-background);
}
&:active {
transform: scale(0.98);
box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.08), 0 6px 8px -8px rgba(19, 16, 16, 0.50);
}
}
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
}
[data-component="faq"] {
border-top: 1px solid var(--color-border-weak);
padding: 4rem 5rem;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
[data-slot="section-title"] {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 500;
color: var(--color-text-strong);
margin-bottom: 12px;
}
p {
margin-bottom: 12px;
color: var(--color-text);
}
}
ul {
padding: 0;
li {
list-style: none;
margin-bottom: 24px;
line-height: 200%;
@media (max-width: 60rem) {
line-height: 180%;
}
}
}
[data-slot="faq-question"] {
display: flex;
gap: 16px;
margin-bottom: 8px;
color: var(--color-text-strong);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
padding: 0;
[data-slot="faq-icon-plus"] {
flex-shrink: 0;
color: var(--color-text-weak);
margin-top: 2px;
[data-closed] & {
display: block;
}
[data-expanded] & {
display: none;
}
}
[data-slot="faq-icon-minus"] {
flex-shrink: 0;
color: var(--color-text-weak);
margin-top: 2px;
[data-closed] & {
display: none;
}
[data-expanded] & {
display: block;
}
}
[data-slot="faq-question-text"] {
flex-grow: 1;
text-align: left;
}
}
[data-slot="faq-answer"] {
margin-left: 40px;
margin-bottom: 32px;
color: var(--color-text);
}
}
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
padding: 2rem 5rem;
display: flex;
gap: 32px;
justify-content: center;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
text-decoration-thickness: 2px;
}
}
}
@@ -0,0 +1,352 @@
import "./index.css"
import { Title, Meta } from "@solidjs/meta"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
import previewLogoDark from "../../asset/brand/preview-opencode-logo-dark.png"
import previewWordmarkLight from "../../asset/brand/preview-opencode-wordmark-light.png"
import previewWordmarkDark from "../../asset/brand/preview-opencode-wordmark-dark.png"
import previewWordmarkSimpleLight from "../../asset/brand/preview-opencode-wordmark-simple-light.png"
import previewWordmarkSimpleDark from "../../asset/brand/preview-opencode-wordmark-simple-dark.png"
import logoLightPng from "../../asset/brand/opencode-logo-light.png"
import logoDarkPng from "../../asset/brand/opencode-logo-dark.png"
import wordmarkLightPng from "../../asset/brand/opencode-wordmark-light.png"
import wordmarkDarkPng from "../../asset/brand/opencode-wordmark-dark.png"
import wordmarkSimpleLightPng from "../../asset/brand/opencode-wordmark-simple-light.png"
import wordmarkSimpleDarkPng from "../../asset/brand/opencode-wordmark-simple-dark.png"
import logoLightSvg from "../../asset/brand/opencode-logo-light.svg"
import logoDarkSvg from "../../asset/brand/opencode-logo-dark.svg"
import wordmarkLightSvg from "../../asset/brand/opencode-wordmark-light.svg"
import wordmarkDarkSvg from "../../asset/brand/opencode-wordmark-dark.svg"
import wordmarkSimpleLightSvg from "../../asset/brand/opencode-wordmark-simple-light.svg"
import wordmarkSimpleDarkSvg from "../../asset/brand/opencode-wordmark-simple-dark.svg"
const brandAssets = "/opencode-brand-assets.zip"
export default function Brand() {
const downloadFile = async (url: string, filename: string) => {
try {
const response = await fetch(url)
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
} catch (error) {
console.error("Download failed:", error)
const link = document.createElement("a")
link.href = url
link.target = "_blank"
link.rel = "noopener noreferrer"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
return (
<main data-page="enterprise">
<Title>OpenCode | Brand</Title>
<Meta name="description" content="OpenCode brand guidelines" />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="brand-content">
<h2>Brand guidelines</h2>
<p>Resources and assets to help you work with the OpenCode brand.</p>
<button
data-component="download-button"
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
>
Download all assets
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<div data-component="brand-grid">
<div>
<img src={previewLogoLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button onClick={() => downloadFile(logoLightSvg, "opencode-logo-light.svg")}>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
<div>
<img src={previewLogoDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button onClick={() => downloadFile(logoDarkSvg, "opencode-logo-dark.svg")}>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
<div>
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button
onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}
>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button
onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}
>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
<div>
<img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button
onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}
>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button
onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}
>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
<div>
<img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button
onClick={() =>
downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")
}
>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button
onClick={() =>
downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")
}
>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
<div>
<img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button
onClick={() =>
downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")
}
>
PNG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
<button
onClick={() =>
downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")
}
>
SVG
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</button>
</div>
</div>
</div>
</section>
</div>
<Footer />
</div>
<Legal />
</main>
)
}
@@ -0,0 +1,550 @@
::selection {
background: var(--color-background-interactive);
color: var(--color-text-strong);
@media (prefers-color-scheme: dark) {
background: var(--color-background-interactive);
color: var(--color-text-inverted);
}
}
[data-page="enterprise"] {
--color-background: hsl(0, 20%, 99%);
--color-background-weak: hsl(0, 8%, 97%);
--color-background-weak-hover: hsl(0, 8%, 94%);
--color-background-strong: hsl(0, 5%, 12%);
--color-background-strong-hover: hsl(0, 5%, 18%);
--color-background-interactive: hsl(62, 84%, 88%);
--color-background-interactive-weaker: hsl(64, 74%, 95%);
--color-text: hsl(0, 1%, 39%);
--color-text-weak: hsl(0, 1%, 60%);
--color-text-weaker: hsl(30, 2%, 81%);
--color-text-strong: hsl(0, 5%, 12%);
--color-text-inverted: hsl(0, 20%, 99%);
--color-text-success: hsl(119, 100%, 35%);
--color-border: hsl(30, 2%, 81%);
--color-border-weak: hsl(0, 1%, 85%);
--color-icon: hsl(0, 1%, 55%);
--color-success: hsl(142, 76%, 36%);
background: var(--color-background);
font-family: var(--font-mono);
color: var(--color-text);
padding-bottom: 5rem;
@media (prefers-color-scheme: dark) {
--color-background: hsl(0, 9%, 7%);
--color-background-weak: hsl(0, 6%, 10%);
--color-background-weak-hover: hsl(0, 6%, 15%);
--color-background-strong: hsl(0, 15%, 94%);
--color-background-strong-hover: hsl(0, 15%, 97%);
--color-background-interactive: hsl(62, 100%, 90%);
--color-background-interactive-weaker: hsl(60, 20%, 8%);
--color-text: hsl(0, 4%, 71%);
--color-text-weak: hsl(0, 2%, 49%);
--color-text-weaker: hsl(0, 3%, 28%);
--color-text-strong: hsl(0, 15%, 94%);
--color-text-inverted: hsl(0, 9%, 7%);
--color-text-success: hsl(119, 60%, 72%);
--color-border: hsl(0, 3%, 28%);
--color-border-weak: hsl(0, 4%, 23%);
--color-icon: hsl(10, 3%, 43%);
--color-success: hsl(142, 76%, 46%);
}
/* Header and Footer styles - copied from index.css */
[data-component="top"] {
padding: 24px 5rem;
height: 80px;
position: sticky;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--color-background);
border-bottom: 1px solid var(--color-border-weak);
z-index: 10;
@media (max-width: 60rem) {
padding: 24px 1.5rem;
}
img {
height: 34px;
width: auto;
}
[data-component="nav-desktop"] {
ul {
display: flex;
justify-content: space-between;
gap: 48px;
li {
display: inline-block;
a {
text-decoration: none;
span {
color: var(--color-text-weak);
}
}
a:hover {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
}
@media (max-width: 40rem) {
display: none;
}
}
[data-component="nav-mobile"] {
button > svg {
color: var(--color-icon);
}
}
[data-component="nav-mobile-toggle"] {
border: none;
background: none;
outline: none;
height: 40px;
width: 40px;
cursor: pointer;
margin-right: -8px;
}
[data-component="nav-mobile-toggle"]:hover {
background: var(--color-background-weak);
}
[data-component="nav-mobile"] {
display: none;
@media (max-width: 40rem) {
display: block;
[data-component="nav-mobile-icon"] {
cursor: pointer;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
}
[data-component="nav-mobile-menu-list"] {
position: fixed;
background: var(--color-background);
top: 80px;
left: 0;
right: 0;
height: 100vh;
ul {
list-style: none;
padding: 20px 0;
li {
a {
text-decoration: none;
padding: 20px;
display: block;
span {
color: var(--color-text-weak);
}
}
a:hover {
background: var(--color-background-weak);
}
}
}
}
}
}
[data-slot="logo dark"] {
display: none;
}
@media (prefers-color-scheme: dark) {
[data-slot="logo light"] {
display: none;
}
[data-slot="logo dark"] {
display: block;
}
}
}
[data-component="footer"] {
border-top: 1px solid var(--color-border-weak);
display: flex;
flex-direction: row;
@media (max-width: 65rem) {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
width: 100%;
display: block;
span {
color: var(--color-text-weak);
@media (max-width: 40rem) {
display: none;
}
}
}
a:hover {
background: var(--color-background-weak);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
}
}
[data-slot="cell"] + [data-slot="cell"] {
border-left: 1px solid var(--color-border-weak);
@media (max-width: 40rem) {
border-left: none;
}
}
/* Mobile: third column on its own row */
@media (max-width: 25rem) {
flex-wrap: wrap;
[data-slot="cell"] {
flex: 1 0 100%;
border-left: none;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="cell"]:nth-child(1) {
border-top: none;
}
}
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
border: 1px solid var(--color-border-weak);
border-top: none;
@media (max-width: 65rem) {
border: none;
}
}
[data-component="content"] {
}
[data-component="enterprise-content"] {
padding: 4rem 0;
@media (max-width: 60rem) {
padding: 2rem 0;
}
}
[data-component="enterprise-columns"] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
padding: 4rem 5rem;
@media (max-width: 80rem) {
gap: 3rem;
}
@media (max-width: 60rem) {
grid-template-columns: 1fr;
gap: 3rem;
padding: 2rem 1.5rem;
}
}
[data-component="enterprise-column-1"] {
h2 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
margin-bottom: 1rem;
}
h3 {
font-size: 1.25rem;
font-weight: 500;
color: var(--color-text-strong);
margin: 2rem 0 1rem 0;
}
p {
line-height: 1.6;
margin-bottom: 1.5rem;
color: var(--color-text);
}
[data-component="testimonial"] {
margin-top: 4rem;
font-weight: 500;
color: var(--color-text-strong);
[data-component="quotation"] {
svg {
margin-bottom: 1rem;
opacity: 20%;
}
}
[data-component="testimonial-logo"] {
svg {
margin-top: 1.5rem;
}
}
}
}
[data-component="enterprise-column-2"] {
[data-component="enterprise-form"] {
padding: 0;
h2 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
margin-bottom: 1.5rem;
}
[data-component="form-group"] {
margin-bottom: 1.5rem;
label {
display: block;
font-weight: 500;
color: var(--color-text-weak);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
transition: background-color 5000000s ease-in-out 0s;
}
input:-webkit-autofill {
-webkit-text-fill-color: var(--color-text-strong) !important;
}
input:-moz-autofill {
-moz-text-fill-color: var(--color-text-strong) !important;
}
input,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border-weak);
border-radius: 4px;
background: var(--color-background-weak);
color: var(--color-text-strong);
font-family: inherit;
&::placeholder {
color: var(--color-text-weak);
}
&:focus {
background: var(--color-background-interactive-weaker);
outline: none;
border: none;
color: var(--color-text-strong);
border: 1px solid var(--color-background-strong);
box-shadow: 0 0 0 3px var(--color-background-interactive);
@media (prefers-color-scheme: dark) {
box-shadow: none;
border: 1px solid var(--color-background-interactive);
}
}
}
textarea {
resize: vertical;
min-height: 120px;
}
}
[data-component="submit-button"] {
padding: 0.5rem 1.5rem;
background: var(--color-background-strong);
color: var(--color-text-inverted);
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover:not(:disabled) {
background: var(--color-background-strong-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
[data-component="success-message"] {
margin-top: 1rem;
padding: 1rem 0;
color: var(--color-text-success);
text-align: left;
}
}
}
[data-component="faq"] {
border-top: 1px solid var(--color-border-weak);
padding: 4rem 5rem;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
[data-slot="section-title"] {
margin-bottom: 24px;
h3 {
font-size: 16px;
font-weight: 500;
color: var(--color-text-strong);
margin-bottom: 12px;
}
p {
margin-bottom: 12px;
color: var(--color-text);
}
}
ul {
padding: 0;
li {
list-style: none;
margin-bottom: 24px;
line-height: 200%;
@media (max-width: 60rem) {
line-height: 180%;
}
}
}
[data-slot="faq-question"] {
display: flex;
gap: 16px;
margin-bottom: 8px;
color: var(--color-text-strong);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
padding: 0;
[data-slot="faq-icon-plus"] {
flex-shrink: 0;
color: var(--color-text-weak);
margin-top: 2px;
[data-closed] & {
display: block;
}
[data-expanded] & {
display: none;
}
}
[data-slot="faq-icon-minus"] {
flex-shrink: 0;
color: var(--color-text-weak);
margin-top: 2px;
[data-closed] & {
display: none;
}
[data-expanded] & {
display: block;
}
}
[data-slot="faq-question-text"] {
flex-grow: 1;
text-align: left;
}
}
[data-slot="faq-answer"] {
margin-left: 40px;
margin-bottom: 32px;
color: var(--color-text);
}
}
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
padding: 2rem 5rem;
display: flex;
gap: 32px;
justify-content: center;
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
a {
color: var(--color-text-strong);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
text-decoration-thickness: 2px;
}
}
}
@@ -0,0 +1,268 @@
import "./index.css"
import { Title, Meta } from "@solidjs/meta"
import { createSignal, Show } from "solid-js"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { Faq } from "~/component/faq"
export default function Enterprise() {
const [formData, setFormData] = createSignal({
name: "",
role: "",
email: "",
message: "",
})
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [showSuccess, setShowSuccess] = createSignal(false)
const handleInputChange = (field: string) => (e: Event) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement
setFormData((prev) => ({ ...prev, [field]: target.value }))
}
const handleSubmit = async (e: Event) => {
e.preventDefault()
setIsSubmitting(true)
try {
const response = await fetch("/api/enterprise", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData()),
})
if (response.ok) {
setShowSuccess(true)
setFormData({
name: "",
role: "",
email: "",
message: "",
})
setTimeout(() => setShowSuccess(false), 5000)
}
} catch (error) {
console.error("Failed to submit form:", error)
} finally {
setIsSubmitting(false)
}
}
return (
<main data-page="enterprise">
<Title>OpenCode | Enterprise solutions for your organisation</Title>
<Meta name="description" content="Contact OpenCode for enterprise solutions" />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="enterprise-content">
<div data-component="enterprise-columns">
<div data-component="enterprise-column-1">
<h2>Your code is yours</h2>
<p>
OpenCode operates securely inside your organization with no data or context stored
and no licensing restrictions or ownership claims. Start a trial with your team,
then deploy it across your organization by integrating it with your SSO and
internal AI gateway.
</p>
<p>Let us know and how we can help.</p>
<Show when={false}>
<div data-component="testimonial">
<div data-component="quotation">
<svg
width="20"
height="17"
viewBox="0 0 20 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
fill="currentColor"
/>
</svg>
</div>
Thanks to OpenCode, we found a way to create software to track all our assets
even the imaginary ones.
<div data-component="testimonial-logo">
<svg
width="80"
height="79"
viewBox="0 0 80 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0 39.3087L10.0579 29.251L15.6862 34.7868L13.7488 36.7248L10.3345 33.2186L8.48897 35.0639L11.8111 38.4781L9.96557 40.4156L6.55181 37.0018L4.06028 39.4928L7.56674 42.9991L5.62884 44.845L0 39.3087Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.7182 36.8164L20.2094 39.4003L16.6108 46.9666L22.2393 41.3374L24.3615 43.46L14.2118 53.5179L11.9047 51.1187L15.4112 43.3677L9.78254 49.0888L7.66016 46.9666L17.7182 36.8164Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M42.8139 61.915L45.3055 64.4064L41.6145 71.9731L47.243 66.3441L49.3652 68.4663L39.3077 78.5244L36.9088 76.1252L40.5072 68.374L34.7866 74.0953L32.6641 71.9731L42.8139 61.915Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.4258 55.7324L26.4833 45.582L28.6061 47.7042C31.0049 50.1034 32.3892 51.9497 30.1746 54.1642C28.7902 55.548 27.6831 56.0094 26.1145 54.9016L26.0222 54.994C27.2218 56.1941 26.9448 57.1162 25.4688 58.5931L23.9 60.1615C23.4383 60.6232 22.8847 61.2693 22.7927 62.0067L20.6705 59.8845C20.7625 59.146 21.3161 58.5008 21.778 58.1316L23.5307 56.3788C24.269 55.6403 23.715 54.2555 23.254 53.8872L22.8847 53.4256L18.548 57.7623L16.4258 55.7324ZM24.3611 51.9495C25.4689 53.0563 26.4833 53.3332 27.4984 52.3178C28.5134 51.3957 28.2367 50.3802 27.1295 49.1812L24.3611 51.9495Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M33.4952 66.9899C31.096 69.3891 28.8815 68.4659 27.4047 66.9899C26.021 65.6062 25.0978 63.3907 27.4972 60.9003L31.8336 56.6548C34.2333 54.2556 36.4478 55.0864 37.9241 56.5635C39.308 58.0396 40.2311 60.2541 37.8315 62.6531L33.4952 66.9899ZM29.0659 63.5752C28.6048 64.0369 28.6048 64.7753 29.1583 65.3292C29.6196 65.8821 30.4502 65.7897 30.8194 65.4215L36.2633 59.9769C36.7246 59.6076 36.7246 58.7779 36.171 58.3164C35.7097 57.7626 34.8791 57.7626 34.5101 58.2241L29.0659 63.5752Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M78.5267 39.308L68.2845 29.0654L47.5231 49.735L49.6453 51.8572L68.2845 33.2179L74.3746 39.308L47.2461 66.3435L49.3683 68.4657L78.5267 39.308Z"
fill="#0083C6"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M49.6443 51.8577L43.3695 45.4902L64.0386 24.8215L53.7969 14.4873L33.0352 35.2482L35.1574 37.3705L53.7969 18.7315L59.7947 24.8215L39.1251 45.4902L47.5221 53.9799L49.6443 51.8577Z"
fill="#2D9C5C"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.1564 37.3706L28.7896 31.0038L49.5515 10.3347L39.3088 0L10.0586 29.2507L12.1804 31.2804L39.3088 4.24476L45.3066 10.3347L24.6377 31.0038L33.0342 39.4008L35.1564 37.3706Z"
fill="#E92A35"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M77.2332 52.4105C76.0336 52.4105 75.111 51.4884 75.111 50.196C75.111 48.9046 76.0336 47.9814 77.2332 47.9814C78.3405 47.9814 79.263 48.9046 79.263 50.196C79.263 51.4884 78.3405 52.4105 77.2332 52.4105ZM77.2332 52.9643C78.7098 52.9643 80.0015 51.6729 80.0015 50.196C80.0015 48.6276 78.7096 47.4287 77.2332 47.4287C75.6644 47.4287 74.4648 48.6278 74.4648 50.196C74.4647 51.6731 75.6643 52.9643 77.2332 52.9643ZM76.1259 51.7653H76.6797V50.3804H77.0485L77.8788 51.7653H78.4332L77.6023 50.3804C78.1558 50.2881 78.4332 50.0122 78.4332 49.5507C78.4332 48.9046 78.0633 48.6276 77.3253 48.6276H76.1257V51.7653H76.1259ZM76.6797 49.0892H77.2332C77.5102 49.0892 77.8788 49.0892 77.8788 49.4586C77.8788 49.9202 77.6023 49.9202 77.2332 49.9202H76.6797V49.0892Z"
fill="#0083C6"
/>
</svg>
</div>
</div>
</Show>
</div>
<div data-component="enterprise-column-2">
<div data-component="enterprise-form">
<form onSubmit={handleSubmit}>
<div data-component="form-group">
<label for="name">Full name</label>
<input
id="name"
type="text"
required
value={formData().name}
onInput={handleInputChange("name")}
placeholder="Jeff Bezos"
/>
</div>
<div data-component="form-group">
<label for="role">Role</label>
<input
id="role"
type="text"
required
value={formData().role}
onInput={handleInputChange("role")}
placeholder="Executive Chairman"
/>
</div>
<div data-component="form-group">
<label for="email">Company email</label>
<input
id="email"
type="email"
required
value={formData().email}
onInput={handleInputChange("email")}
placeholder="jeff@amazon.com"
/>
</div>
<div data-component="form-group">
<label for="message">What problem are you trying to solve?</label>
<textarea
id="message"
required
rows={5}
value={formData().message}
onInput={handleInputChange("message")}
placeholder="We need help with..."
/>
</div>
<button type="submit" disabled={isSubmitting()} data-component="submit-button">
{isSubmitting() ? "Sending..." : "Send"}
</button>
</form>
{showSuccess() && (
<div data-component="success-message">
Message sent, we'll be in touch soon.
</div>
)}
</div>
</div>
</div>
</section>
<section data-component="faq">
<div data-slot="section-title">
<h3>FAQ</h3>
</div>
<ul>
<li>
<Faq question="What is OpenCode Enterprise?">
OpenCode Enterprise is for organizations that want to ensure that their code and
data never leaves their infrastructure. It can do this by using a centralized
config that integrates with your SSO and internal AI gateway.
</Faq>
</li>
<li>
<Faq question="How do I get started with OpenCode Enterprise?">
Simply start with an internal trial with your team. OpenCode by default does not
store your code or context data, making it easy to get started. Then contact us to
discuss pricing and implementation options.
</Faq>
</li>
<li>
<Faq question="How does enterprise pricing work?">
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not
charge for tokens used. For further details, contact us for a custom quote based
on your organization's needs.
</Faq>
</li>
<li>
<Faq question="Is my data secure with OpenCode Enterprise?">
Yes. OpenCode does not store your code or context data. All processing happens
locally or through direct API calls to your AI provider. With central config and
SSO integration, your data remains secure within your organization's
infrastructure.
</Faq>
</li>
</ul>
</section>
</div>
<Footer />
</div>
<Legal />
</main>
)
}
+17 -28
View File
@@ -56,7 +56,6 @@ body {
}
}
@supports (background: -webkit-named-image(i)) {
[data-page="opencode"] {
border-top: 1px solid var(--color-border-weak);
@@ -118,7 +117,6 @@ body {
display: none !important;
}
[data-component="growth-stat"] {
display: flex;
flex-direction: column;
@@ -149,14 +147,12 @@ body {
display: none;
}
}
}
[data-component="stat-illustration"] {
width: 100%;
height: 100%;
display: block;
width: 100%;
height: 100%;
display: block;
svg {
margin: 0;
@@ -164,10 +160,9 @@ body {
height: auto;
display: block;
}
}
}
}
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
@@ -184,7 +179,6 @@ body {
-moz-text-fill-color: var(--color-text-strong) !important;
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
@@ -328,10 +322,10 @@ body {
display: flex;
flex-direction: column;
max-width: 100%;
padding: calc(var(--vertical-padding)*2) var(--padding);
padding: calc(var(--vertical-padding) * 2) var(--padding);
@media (max-width: 30rem) {
padding: var(--vertical-padding) var(--padding)
padding: var(--vertical-padding) var(--padding);
}
@media (prefers-color-scheme: dark) {
@@ -352,7 +346,6 @@ body {
@media (max-width: 550px) {
width: calc(100vw - 48px);
}
}
[data-component="tabs"] {
@@ -368,7 +361,7 @@ body {
padding: 0 20px;
@media (max-width: 60rem) {
gap: 20px;
gap: 32px;
overflow-x: auto;
width: 100%;
}
@@ -443,7 +436,6 @@ body {
@media (max-width: 35rem) {
width: calc(100% - 40px) !important;
}
}
[data-slot="highlight"] {
@@ -496,7 +488,6 @@ body {
@media (max-width: 60rem) {
font-size: 22px;
}
}
p {
@@ -579,7 +570,6 @@ body {
margin-bottom: 24px;
max-width: 100%;
div {
display: flex;
gap: 12px;
@@ -827,16 +817,12 @@ body {
outline: none;
border: none;
color: var(--color-text-strong);
border: 1px solid var(--color-background-strong); /* Tailwind blue-600 as example */
/* Tailwind-style ring */
border: 1px solid var(--color-background-strong);
box-shadow: 0 0 0 3px var(--color-background-interactive);
/* mimics "ring-2 ring-blue-600/50" */
@media (prefers-color-scheme: dark) {
box-shadow: none;
border: 1px solid var(--color-background-interactive)
border: 1px solid var(--color-background-interactive);
}
}
@@ -896,7 +882,6 @@ body {
margin-top: 2px;
}
[data-slot="faq-icon-plus"] {
flex-shrink: 0;
color: var(--color-text-weak);
@@ -1025,7 +1010,6 @@ body {
}
[data-component="copy-status"] {
[data-slot="copy"] {
display: block;
width: var(--space-4);
@@ -1058,12 +1042,10 @@ body {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
@@ -1076,7 +1058,6 @@ body {
@media (max-width: 40rem) {
display: none;
}
}
}
@@ -1115,10 +1096,18 @@ body {
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
display: flex;
gap: 32px;
justify-content: center;
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
}
+5 -5
View File
@@ -219,8 +219,8 @@ export default function Home() {
<div>
<span>[*]</span>
<p>
With over <strong>26,000</strong> GitHub stars, <strong>188</strong> contributors, and almost{" "}
<strong>3,000</strong> commits, OpenCode is used and trusted by over <strong>200,000</strong>{" "}
With over <strong>29,000</strong> GitHub stars, <strong>230</strong> contributors, and almost{" "}
<strong>3,500</strong> commits, OpenCode is used and trusted by over <strong>250,000</strong>{" "}
developers every month.
</p>
</div>
@@ -274,7 +274,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 1.</figure> <strong>26K</strong> GitHub Stars
<figure>Fig 1.</figure> <strong>29K</strong> GitHub Stars
</span>
</div>
@@ -577,7 +577,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 2.</figure> <strong>188</strong> Contributors
<figure>Fig 2.</figure> <strong>230</strong> Contributors
</span>
</div>
@@ -619,7 +619,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 3.</figure> <strong>200K</strong> Monthly Devs
<figure>Fig 3.</figure> <strong>250K</strong> Monthly Devs
</span>
</div>
</div>
@@ -16,7 +16,7 @@ export function formatDateForTable(date: Date) {
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
return date.toLocaleDateString(undefined, options).replace(",", ",")
}
export function formatDateUTC(date: Date) {
+10 -13
View File
@@ -60,7 +60,6 @@ body {
}
}
[data-page="zen"] {
background: var(--color-background);
--padding: 5rem;
@@ -112,9 +111,6 @@ body {
-moz-text-fill-color: var(--color-text-strong) !important;
}
[data-component="container"] {
max-width: 67.5rem;
margin: 0 auto;
@@ -179,8 +175,6 @@ body {
}
}
[data-component="nav-mobile-toggle"] {
border: none;
background: none;
@@ -258,14 +252,14 @@ body {
[data-component="hero"] {
display: flex;
flex-direction: column;
padding: calc(var(--vertical-padding)*2) var(--padding);
padding: calc(var(--vertical-padding) * 2) var(--padding);
[data-slot="zen logo dark"] {
display: none;
}
@media (max-width: 30rem) {
padding: var(--vertical-padding) var(--padding)
padding: var(--vertical-padding) var(--padding);
}
@media (prefers-color-scheme: dark) {
@@ -295,8 +289,6 @@ body {
}
}
p {
color: var(--color-text);
margin-bottom: 24px;
@@ -778,12 +770,10 @@ body {
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="cell"] {
flex: 1;
text-align: center;
a {
text-decoration: none;
padding: 2rem 0;
@@ -796,7 +786,6 @@ body {
@media (max-width: 40rem) {
display: none;
}
}
}
@@ -835,10 +824,18 @@ body {
[data-component="legal"] {
color: var(--color-text-weak);
text-align: center;
display: flex;
gap: 32px;
justify-content: center;
a {
color: var(--color-text-weak);
text-decoration: none;
}
a:hover {
color: var(--color-text);
text-decoration: underline;
}
}
}
@@ -0,0 +1,5 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
@@ -0,0 +1 @@
export type Format = "anthropic" | "openai" | "oa-compat"
@@ -1,67 +1,45 @@
import { z } from "zod"
import type { APIEvent } from "@solidjs/start/server"
import path from "node:path"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
import { Resource } from "@opencode-ai/console-resource"
import { Billing } from "../../../../core/src/billing"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
import {
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
} from "./provider/provider"
import { Format } from "./format"
import { anthropicHelper } from "./provider/anthropic"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type Model = ZenData["models"][string]
export async function handler(
input: APIEvent,
opts: {
modifyBody?: (body: any) => any
setAuthHeader: (headers: Headers, apiKey: string) => void
format: Format
parseApiKey: (headers: Headers) => string | undefined
onStreamPart: (chunk: string) => void
getStreamUsage: () => any
normalizeUsage: (body: any) => {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
},
) {
class AuthError extends Error {}
class CreditsError extends Error {}
class MonthlyLimitError extends Error {}
class UserLimitError extends Error {}
class ModelError extends Error {}
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type Model = ZenData["models"][string]
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
]
const logger = {
metric: (values: Record<string, any>) => {
console.log(`_metric:${JSON.stringify(values)}`)
},
log: console.log,
debug: (message: string) => {
if (Resource.App.stage === "production") return
console.debug(message)
},
}
try {
const url = new URL(input.request.url)
const body = await input.request.json()
logger.debug(JSON.stringify(body))
logger.metric({
is_tream: !!body.stream,
session: input.request.headers.get("x-opencode-session"),
@@ -69,7 +47,11 @@ export async function handler(
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, body.model)
const providerInfo = selectProvider(zenData, modelInfo)
const providerInfo = selectProvider(
zenData,
modelInfo,
input.request.headers.get("x-real-ip") ?? "",
)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(modelInfo, authInfo)
validateModelSettings(authInfo)
@@ -78,22 +60,28 @@ export async function handler(
// Request to model provider
const startTimestamp = Date.now()
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen\/v1/, "") + url.search), {
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody)
const res = await fetch(reqUrl, {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
opts.setAuthHeader(headers, providerInfo.apiKey)
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...(opts.modifyBody?.(body) ?? body),
model: providerInfo.model,
}),
body: reqBody,
})
// Scrub response headers
@@ -104,14 +92,19 @@ export async function handler(
resHeaders.set(k, v)
}
}
logger.debug("STATUS: " + res.status + " " + res.statusText)
if (res.status === 400 || res.status === 503) {
logger.debug("RESPONSE: " + (await res.text()))
}
// Handle non-streaming response
if (!body.stream) {
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
const json = await res.json()
const body = JSON.stringify(json)
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
logger.debug("RESPONSE: " + body)
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo)
return new Response(body, {
status: res.status,
@@ -121,10 +114,13 @@ export async function handler(
}
// Handle streaming response
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser()
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
let buffer = ""
let responseLength = 0
@@ -136,9 +132,9 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
const usage = opts.getStreamUsage()
const usage = usageParser.retrieve()
if (usage) {
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
await trackUsage(authInfo, modelInfo, providerInfo, usage)
await reload(authInfo)
}
c.close()
@@ -158,12 +154,21 @@ export async function handler(
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
for (const part of parts) {
logger.debug(part)
opts.onStreamPart(part.trim())
for (let part of parts) {
logger.debug("PART: " + part)
part = part.trim()
usageParser.parse(part)
if (providerInfo.format !== opts.format) {
part = streamConverter(part)
c.enqueue(encoder.encode(part + "\n\n"))
}
}
c.enqueue(value)
if (providerInfo.format === opts.format) {
c.enqueue(value)
}
return pump()
}) || Promise.resolve()
@@ -225,17 +230,33 @@ export async function handler(
return { id: modelId, ...modelData }
}
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>) {
function selectProvider(
zenData: ZenData,
model: Awaited<ReturnType<typeof validateModel>>,
ip: string,
) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
const provider = providers[Math.floor(Math.random() * providers.length)]
// Use last character of IP address to select a provider
const lastChar = ip.charCodeAt(ip.length - 1) || 0
const index = lastChar % providers.length
const provider = providers[index]
if (!(provider.id in zenData.providers)) {
throw new ModelError(`Provider ${provider.id} not supported`)
}
return { ...provider, ...zenData.providers[provider.id] }
return {
...provider,
...zenData.providers[provider.id],
...(provider.id === "anthropic"
? anthropicHelper
: provider.id === "openai"
? openaiHelper
: oaCompatHelper),
}
}
async function authenticate(
@@ -274,11 +295,20 @@ export async function handler(
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)))
.innerJoin(
UserTable,
and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
)
.leftJoin(
ModelTable,
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
)
.leftJoin(
ProviderTable,
and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
and(
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
eq(ProviderTable.provider, providerInfo.id),
),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
@@ -302,12 +332,20 @@ export async function handler(
}
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo || authInfo.isFree) return
if (!authInfo) return
if (authInfo.provider?.credentials) return
if (authInfo.isFree) return
if (model.allowAnonymous) return
const billing = authInfo.billing
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
if (!billing.paymentMethodID)
throw new CreditsError(
`No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
if (billing.balance <= 0)
throw new CreditsError(
`Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
const now = new Date()
const currentYear = now.getUTCFullYear()
@@ -322,7 +360,7 @@ export async function handler(
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}.`,
`Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
)
}
@@ -335,7 +373,9 @@ export async function handler(
const dateYear = authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new UserLimitError(`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}.`)
throw new UserLimitError(
`You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
)
}
}
@@ -356,15 +396,22 @@ export async function handler(
async function trackUsage(
authInfo: Awaited<ReturnType<typeof authenticate>>,
modelInfo: ReturnType<typeof validateModel>,
providerId: string,
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
usage: any,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
opts.normalizeUsage(usage)
const {
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
} = providerInfo.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
200_000
? modelInfo.cost200K
: modelInfo.cost
@@ -415,13 +462,14 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
const cost =
authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerId,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
@@ -455,7 +503,9 @@ export async function handler(
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
.where(
and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
)
})
await Database.use((tx) =>
@@ -482,7 +532,10 @@ export async function handler(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
or(
isNull(BillingTable.timeReloadLockedTill),
lt(BillingTable.timeReloadLockedTill, sql`now()`),
),
),
),
)
@@ -0,0 +1,12 @@
import { Resource } from "@opencode-ai/console-resource"
export const logger = {
metric: (values: Record<string, any>) => {
console.log(`_metric:${JSON.stringify(values)}`)
},
log: console.log,
debug: (message: string) => {
if (Resource.App.stage === "production") return
console.debug(message)
},
}
@@ -0,0 +1,622 @@
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
type Usage = {
cache_creation?: {
ephemeral_5m_input_tokens?: number
ephemeral_1h_input_tokens?: number
}
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
input_tokens?: number
output_tokens?: number
server_tool_use?: {
web_search_requests?: number
}
}
export const anthropicHelper = {
format: "anthropic",
modifyUrl: (providerApi: string) => providerApi + "/messages",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-api-key", apiKey)
headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01")
if (body.model.startsWith("claude-sonnet-")) {
headers.set("anthropic-beta", "context-1m-2025-08-07")
}
},
modifyBody: (body: Record<string, any>) => {
return {
...body,
service_tier: "standard_only",
}
},
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
const data = chunk.split("\n")[1]
if (!data.startsWith("data: ")) return
let json
try {
json = JSON.parse(data.slice(6))
} catch (e) {
return
}
const usageUpdate = json.usage ?? json.message?.usage
if (!usageUpdate) return
usage = {
...usage,
...usageUpdate,
cache_creation: {
...usage?.cache_creation,
...usageUpdate.cache_creation,
},
server_tool_use: {
...usage?.server_tool_use,
...usageUpdate.server_tool_use,
},
}
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => ({
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
reasoningTokens: undefined,
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
}),
} satisfies ProviderHelper
export function fromAnthropicRequest(body: any): CommonRequest {
if (!body || typeof body !== "object") return body
const msgs: any[] = []
const sys = Array.isArray(body.system) ? body.system : undefined
if (sys && sys.length > 0) {
for (const s of sys) {
if (!s) continue
if ((s as any).type !== "text") continue
if (typeof (s as any).text !== "string") continue
if ((s as any).text.length === 0) continue
msgs.push({ role: "system", content: (s as any).text })
}
}
const toImg = (src: any) => {
if (!src || typeof src !== "object") return undefined
if ((src as any).type === "url" && typeof (src as any).url === "string")
return { type: "image_url", image_url: { url: (src as any).url } }
if (
(src as any).type === "base64" &&
typeof (src as any).media_type === "string" &&
typeof (src as any).data === "string"
)
return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
return undefined
}
const inMsgs = Array.isArray(body.messages) ? body.messages : []
for (const m of inMsgs) {
if (!m || !(m as any).role) continue
if ((m as any).role === "user") {
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
const partsOut: any[] = []
for (const p of partsIn) {
if (!p || !(p as any).type) continue
if ((p as any).type === "text" && typeof (p as any).text === "string")
partsOut.push({ type: "text", text: (p as any).text })
if ((p as any).type === "image") {
const ip = toImg((p as any).source)
if (ip) partsOut.push(ip)
}
if ((p as any).type === "tool_result") {
const id = (p as any).tool_use_id
const content =
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
msgs.push({ role: "tool", tool_call_id: id, content })
}
}
if (partsOut.length > 0) {
if (partsOut.length === 1 && partsOut[0].type === "text") msgs.push({ role: "user", content: partsOut[0].text })
else msgs.push({ role: "user", content: partsOut })
}
continue
}
if ((m as any).role === "assistant") {
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
const texts: string[] = []
const tcs: any[] = []
for (const p of partsIn) {
if (!p || !(p as any).type) continue
if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text)
if ((p as any).type === "tool_use") {
const name = (p as any).name
const id = (p as any).id
const inp = (p as any).input
const input = (() => {
if (typeof inp === "string") return inp
try {
return JSON.stringify(inp ?? {})
} catch {
return String(inp ?? "")
}
})()
tcs.push({ id, type: "function", function: { name, arguments: input } })
}
}
const out: any = { role: "assistant", content: texts.join("") }
if (tcs.length > 0) out.tool_calls = tcs
msgs.push(out)
continue
}
}
const tools = Array.isArray(body.tools)
? body.tools
.filter((t: any) => t && typeof t === "object" && "input_schema" in t)
.map((t: any) => ({
type: "function",
function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
}))
: undefined
const tcin = body.tool_choice
const tc = (() => {
if (!tcin) return undefined
if ((tcin as any).type === "auto") return "auto"
if ((tcin as any).type === "any") return "required"
if ((tcin as any).type === "tool" && typeof (tcin as any).name === "string")
return { type: "function" as const, function: { name: (tcin as any).name } }
return undefined
})()
const stop = (() => {
const v = body.stop_sequences
if (!v) return undefined
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
if (typeof v === "string") return v
return undefined
})()
return {
model: body.model,
max_tokens: body.max_tokens,
temperature: body.temperature,
top_p: body.top_p,
stop,
messages: msgs,
stream: !!body.stream,
tools,
tool_choice: tc,
}
}
export function toAnthropicRequest(body: CommonRequest) {
if (!body || typeof body !== "object") return body
const sysIn = Array.isArray(body.messages) ? body.messages.filter((m: any) => m && m.role === "system") : []
let ccCount = 0
const cc = () => {
ccCount++
return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}
}
const system = sysIn
.filter((m: any) => typeof m.content === "string" && m.content.length > 0)
.map((m: any) => ({ type: "text", text: m.content, ...cc() }))
const msgsIn = Array.isArray(body.messages) ? body.messages : []
const msgsOut: any[] = []
const toSrc = (p: any) => {
if (!p || typeof p !== "object") return undefined
if ((p as any).type === "image_url" && (p as any).image_url) {
const u = (p as any).image_url.url ?? (p as any).image_url
if (typeof u === "string" && u.startsWith("data:")) {
const m = u.match(/^data:([^;]+);base64,(.*)$/)
if (m) return { type: "base64", media_type: m[1], data: m[2] }
}
if (typeof u === "string") return { type: "url", url: u }
}
return undefined
}
for (const m of msgsIn) {
if (!m || !(m as any).role) continue
if ((m as any).role === "user") {
if (typeof (m as any).content === "string") {
msgsOut.push({
role: "user",
content: [{ type: "text", text: (m as any).content, ...cc() }],
})
} else if (Array.isArray((m as any).content)) {
const parts: any[] = []
for (const p of (m as any).content) {
if (!p || !(p as any).type) continue
if ((p as any).type === "text" && typeof (p as any).text === "string")
parts.push({ type: "text", text: (p as any).text, ...cc() })
if ((p as any).type === "image_url") {
const s = toSrc(p)
if (s) parts.push({ type: "image", source: s, ...cc() })
}
}
if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
}
continue
}
if ((m as any).role === "assistant") {
const out: any = { role: "assistant", content: [] as any[] }
if (typeof (m as any).content === "string" && (m as any).content.length > 0) {
;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() })
}
if (Array.isArray((m as any).tool_calls)) {
for (const tc of (m as any).tool_calls) {
if ((tc as any).type === "function" && (tc as any).function) {
let input: any
const a = (tc as any).function.arguments
if (typeof a === "string") {
try {
input = JSON.parse(a)
} catch {
input = a
}
} else input = a
const id = (tc as any).id || `toolu_${Math.random().toString(36).slice(2)}`
;(out.content as any[]).push({
type: "tool_use",
id,
name: (tc as any).function.name,
input,
...cc(),
})
}
}
}
if ((out.content as any[]).length > 0) msgsOut.push(out)
continue
}
if ((m as any).role === "tool") {
msgsOut.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: (m as any).tool_call_id,
content: (m as any).content,
...cc(),
},
],
})
continue
}
}
const tools = Array.isArray(body.tools)
? body.tools
.filter((t: any) => t && typeof t === "object" && (t as any).type === "function")
.map((t: any) => ({
name: (t as any).function.name,
description: (t as any).function.description,
input_schema: (t as any).function.parameters,
...cc(),
}))
: undefined
const tcIn = body.tool_choice
const tool_choice = (() => {
if (!tcIn) return undefined
if (tcIn === "auto") return { type: "auto" }
if (tcIn === "required") return { type: "any" }
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
return { type: "tool", name: (tcIn as any).function.name }
return undefined
})()
const stop_sequences = (() => {
const v = body.stop
if (!v) return undefined
if (Array.isArray(v)) return v
if (typeof v === "string") return [v]
return undefined
})()
return {
max_tokens: body.max_tokens ?? 32_000,
temperature: body.temperature,
top_p: body.top_p,
system: system.length > 0 ? system : undefined,
messages: msgsOut,
stream: !!body.stream,
tools,
tool_choice,
stop_sequences,
}
}
export function fromAnthropicResponse(resp: any): CommonResponse {
if (!resp || typeof resp !== "object") return resp
if (Array.isArray((resp as any).choices)) return resp
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
if (!isAnthropic) return resp
const idIn = (resp as any).id
const id =
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
const model = (resp as any).model
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
const text = blocks
.filter((b) => b && b.type === "text" && typeof (b as any).text === "string")
.map((b: any) => b.text)
.join("")
const tcs = blocks
.filter((b) => b && b.type === "tool_use")
.map((b: any) => {
const name = (b as any).name
const args = (() => {
const inp = (b as any).input
if (typeof inp === "string") return inp
try {
return JSON.stringify(inp ?? {})
} catch {
return String(inp ?? "")
}
})()
const tid =
typeof (b as any).id === "string" && (b as any).id.length > 0
? (b as any).id
: `toolu_${Math.random().toString(36).slice(2)}`
return { id: tid, type: "function" as const, function: { name, arguments: args } }
})
const finish = (r: string | null) => {
if (r === "end_turn") return "stop"
if (r === "tool_use") return "tool_calls"
if (r === "max_tokens") return "length"
if (r === "content_filter") return "content_filter"
return null
}
const u = (resp as any).usage
const usage = (() => {
if (!u) return undefined as any
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
const total = pt != null && ct != null ? pt + ct : undefined
const cached =
typeof (u as any).cache_read_input_tokens === "number" ? (u as any).cache_read_input_tokens : undefined
const details = cached != null ? { cached_tokens: cached } : undefined
return {
prompt_tokens: pt,
completion_tokens: ct,
total_tokens: total,
...(details ? { prompt_tokens_details: details } : {}),
}
})()
return {
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
...(text && text.length > 0 ? { content: text } : {}),
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
},
finish_reason: finish((resp as any).stop_reason ?? null),
},
],
...(usage ? { usage } : {}),
}
}
export function toAnthropicResponse(resp: CommonResponse) {
if (!resp || typeof resp !== "object") return resp
if (!Array.isArray((resp as any).choices)) return resp
const choice = (resp as any).choices[0]
if (!choice) return resp
const message = choice.message
if (!message) return resp
const content: any[] = []
if (typeof message.content === "string" && message.content.length > 0)
content.push({ type: "text", text: message.content })
if (Array.isArray(message.tool_calls)) {
for (const tc of message.tool_calls) {
if ((tc as any).type === "function" && (tc as any).function) {
let input: any
try {
input = JSON.parse((tc as any).function.arguments)
} catch {
input = (tc as any).function.arguments
}
content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
}
}
}
const stop_reason = (() => {
const r = choice.finish_reason
if (r === "stop") return "end_turn"
if (r === "tool_calls") return "tool_use"
if (r === "length") return "max_tokens"
if (r === "content_filter") return "content_filter"
return null
})()
const usage = (() => {
const u = (resp as any).usage
if (!u) return undefined
return {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
}
})()
return {
id: (resp as any).id,
type: "message",
role: "assistant",
content: content.length > 0 ? content : [{ type: "text", text: "" }],
model: (resp as any).model,
stop_reason,
usage,
}
}
export function fromAnthropicChunk(chunk: string): CommonChunk | string {
// Anthropic sends two lines per part: "event: <type>\n" + "data: <json>"
const lines = chunk.split("\n")
const dataLine = lines.find((l) => l.startsWith("data: "))
if (!dataLine) return chunk
let json
try {
json = JSON.parse(dataLine.slice(6))
} catch {
return chunk
}
const out: CommonChunk = {
id: json.id ?? json.message?.id ?? "",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: json.model ?? json.message?.model ?? "",
choices: [],
}
if (json.type === "content_block_start") {
const cb = json.content_block
if (cb?.type === "text") {
out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
} else if (cb?.type === "tool_use") {
out.choices.push({
index: json.index ?? 0,
delta: {
tool_calls: [
{ index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
],
},
finish_reason: null,
})
}
}
if (json.type === "content_block_delta") {
const d = json.delta
if (d?.type === "text_delta") {
out.choices.push({ index: json.index ?? 0, delta: { content: d.text }, finish_reason: null })
} else if (d?.type === "input_json_delta") {
out.choices.push({
index: json.index ?? 0,
delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
finish_reason: null,
})
}
}
if (json.type === "message_delta") {
const d = json.delta
const finish_reason = (() => {
const r = d?.stop_reason
if (r === "end_turn") return "stop"
if (r === "tool_use") return "tool_calls"
if (r === "max_tokens") return "length"
if (r === "content_filter") return "content_filter"
return null
})()
out.choices.push({ index: 0, delta: {}, finish_reason })
}
if (json.usage) {
const u = json.usage
out.usage = {
prompt_tokens: u.input_tokens,
completion_tokens: u.output_tokens,
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
...(u.cache_read_input_tokens ? { prompt_tokens_details: { cached_tokens: u.cache_read_input_tokens } } : {}),
}
}
return out
}
export function toAnthropicChunk(chunk: CommonChunk): string {
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
return JSON.stringify({})
}
const choice = chunk.choices[0]
const delta = choice.delta
if (!delta) return JSON.stringify({})
const result: any = {}
if (delta.content) {
result.type = "content_block_delta"
result.index = 0
result.delta = { type: "text_delta", text: delta.content }
}
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
if (tc.function?.name) {
result.type = "content_block_start"
result.index = tc.index ?? 0
result.content_block = { type: "tool_use", id: tc.id, name: tc.function.name, input: {} }
} else if (tc.function?.arguments) {
result.type = "content_block_delta"
result.index = tc.index ?? 0
result.delta = { type: "input_json_delta", partial_json: tc.function.arguments }
}
}
}
if (choice.finish_reason) {
const stop_reason = (() => {
const r = choice.finish_reason
if (r === "stop") return "end_turn"
if (r === "tool_calls") return "tool_use"
if (r === "length") return "max_tokens"
if (r === "content_filter") return "content_filter"
return null
})()
result.type = "message_delta"
result.delta = { stop_reason, stop_sequence: null }
}
if (chunk.usage) {
const u = chunk.usage
result.usage = {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
}
}
return JSON.stringify(result)
}
@@ -0,0 +1,542 @@
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
type Usage = {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
// used by moonshot
cached_tokens?: number
// used by xai
prompt_tokens_details?: {
text_tokens?: number
audio_tokens?: number
image_tokens?: number
cached_tokens?: number
}
completion_tokens_details?: {
reasoning_tokens?: number
audio_tokens?: number
accepted_prediction_tokens?: number
rejected_prediction_tokens?: number
}
}
export const oaCompatHelper = {
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => {
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
}
},
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
} catch (e) {
return
}
if (!json.usage) return
usage = json.usage
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper
export function fromOaCompatibleRequest(body: any): CommonRequest {
if (!body || typeof body !== "object") return body
const msgsIn = Array.isArray(body.messages) ? body.messages : []
const msgsOut: any[] = []
for (const m of msgsIn) {
if (!m || !m.role) continue
if (m.role === "system") {
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
continue
}
if (m.role === "user") {
if (typeof m.content === "string") {
msgsOut.push({ role: "user", content: m.content })
} else if (Array.isArray(m.content)) {
const parts: any[] = []
for (const p of m.content) {
if (!p || !p.type) continue
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url })
}
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
}
continue
}
if (m.role === "assistant") {
const out: any = { role: "assistant" }
if (typeof m.content === "string") out.content = m.content
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
msgsOut.push(out)
continue
}
if (m.role === "tool") {
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
continue
}
}
return {
model: body.model,
max_tokens: body.max_tokens,
temperature: body.temperature,
top_p: body.top_p,
stop: body.stop,
messages: msgsOut,
stream: !!body.stream,
tools: Array.isArray(body.tools) ? body.tools : undefined,
tool_choice: body.tool_choice,
}
}
export function toOaCompatibleRequest(body: CommonRequest) {
if (!body || typeof body !== "object") return body
const msgsIn = Array.isArray(body.messages) ? body.messages : []
const msgsOut: any[] = []
const toImg = (p: any) => {
if (!p || typeof p !== "object") return undefined
if (p.type === "image_url" && p.image_url) return { type: "image_url", image_url: p.image_url }
const s = (p as any).source
if (!s || typeof s !== "object") return undefined
if (s.type === "url" && typeof s.url === "string") return { type: "image_url", image_url: { url: s.url } }
if (s.type === "base64" && typeof s.media_type === "string" && typeof s.data === "string")
return { type: "image_url", image_url: { url: `data:${s.media_type};base64,${s.data}` } }
return undefined
}
for (const m of msgsIn) {
if (!m || !m.role) continue
if (m.role === "system") {
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
continue
}
if (m.role === "user") {
if (typeof m.content === "string") {
msgsOut.push({ role: "user", content: m.content })
continue
}
if (Array.isArray(m.content)) {
const parts: any[] = []
for (const p of m.content) {
if (!p || !p.type) continue
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
const ip = toImg(p)
if (ip) parts.push(ip)
}
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
}
continue
}
if (m.role === "assistant") {
const out: any = { role: "assistant" }
if (typeof m.content === "string") out.content = m.content
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
msgsOut.push(out)
continue
}
if (m.role === "tool") {
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
continue
}
}
const tools = Array.isArray(body.tools)
? body.tools.map((tool: any) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
}))
: undefined
return {
model: body.model,
max_tokens: body.max_tokens,
temperature: body.temperature,
top_p: body.top_p,
stop: body.stop,
messages: msgsOut,
stream: !!body.stream,
tools,
tool_choice: body.tool_choice,
response_format: (body as any).response_format,
}
}
export function fromOaCompatibleResponse(resp: any): CommonResponse {
if (!resp || typeof resp !== "object") return resp
if (!Array.isArray((resp as any).choices)) return resp
const choice = (resp as any).choices[0]
if (!choice) return resp
const message = choice.message
if (!message) return resp
const content: any[] = []
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content })
}
if (Array.isArray(message.tool_calls)) {
for (const toolCall of message.tool_calls) {
if (toolCall.type === "function" && toolCall.function) {
let input
try {
input = JSON.parse(toolCall.function.arguments)
} catch {
input = toolCall.function.arguments
}
content.push({
type: "tool_use",
id: toolCall.id,
name: toolCall.function.name,
input,
})
}
}
}
const stopReason = (() => {
const reason = choice.finish_reason
if (reason === "stop") return "stop"
if (reason === "tool_calls") return "tool_calls"
if (reason === "length") return "length"
if (reason === "content_filter") return "content_filter"
return null
})()
const usage = (() => {
const u = (resp as any).usage
if (!u) return undefined
return {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
...(u.prompt_tokens_details?.cached_tokens
? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
: {}),
}
})()
return {
id: (resp as any).id,
object: "chat.completion" as const,
created: Math.floor(Date.now() / 1000),
model: (resp as any).model,
choices: [
{
index: 0,
message: {
role: "assistant" as const,
...(content.length > 0 && content.some((c) => c.type === "text")
? {
content: content
.filter((c) => c.type === "text")
.map((c: any) => c.text)
.join(""),
}
: {}),
...(content.length > 0 && content.some((c) => c.type === "tool_use")
? {
tool_calls: content
.filter((c) => c.type === "tool_use")
.map((c: any) => ({
id: c.id,
type: "function" as const,
function: {
name: c.name,
arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input),
},
})),
}
: {}),
},
finish_reason: stopReason,
},
],
...(usage ? { usage } : {}),
}
}
export function toOaCompatibleResponse(resp: CommonResponse) {
if (!resp || typeof resp !== "object") return resp
if (Array.isArray((resp as any).choices)) return resp
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
if (!isAnthropic) return resp
const idIn = (resp as any).id
const id =
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
const model = (resp as any).model
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
const text = blocks
.filter((b) => b && b.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join("")
const tcs = blocks
.filter((b) => b && b.type === "tool_use")
.map((b) => {
const name = (b as any).name
const args = (() => {
const inp = (b as any).input
if (typeof inp === "string") return inp
try {
return JSON.stringify(inp ?? {})
} catch {
return String(inp ?? "")
}
})()
const tid =
typeof (b as any).id === "string" && (b as any).id.length > 0
? (b as any).id
: `toolu_${Math.random().toString(36).slice(2)}`
return { id: tid, type: "function" as const, function: { name, arguments: args } }
})
const finish = (r: string | null) => {
if (r === "end_turn") return "stop"
if (r === "tool_use") return "tool_calls"
if (r === "max_tokens") return "length"
if (r === "content_filter") return "content_filter"
return null
}
const u = (resp as any).usage
const usage = (() => {
if (!u) return undefined as any
const pt = typeof u.input_tokens === "number" ? u.input_tokens : undefined
const ct = typeof u.output_tokens === "number" ? u.output_tokens : undefined
const total = pt != null && ct != null ? pt + ct : undefined
const cached = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined
const details = cached != null ? { cached_tokens: cached } : undefined
return {
prompt_tokens: pt,
completion_tokens: ct,
total_tokens: total,
...(details ? { prompt_tokens_details: details } : {}),
}
})()
return {
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
...(text && text.length > 0 ? { content: text } : {}),
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
},
finish_reason: finish((resp as any).stop_reason ?? null),
},
],
...(usage ? { usage } : {}),
}
}
export function fromOaCompatibleChunk(chunk: string): CommonChunk | string {
if (!chunk.startsWith("data: ")) return chunk
let json
try {
json = JSON.parse(chunk.slice(6))
} catch {
return chunk
}
if (!json.choices || !Array.isArray(json.choices) || json.choices.length === 0) {
return chunk
}
const choice = json.choices[0]
const delta = choice.delta
if (!delta) return chunk
const result: CommonChunk = {
id: json.id ?? "",
object: "chat.completion.chunk",
created: json.created ?? Math.floor(Date.now() / 1000),
model: json.model ?? "",
choices: [],
}
if (delta.content) {
result.choices.push({
index: choice.index ?? 0,
delta: { content: delta.content },
finish_reason: null,
})
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
result.choices.push({
index: choice.index ?? 0,
delta: {
tool_calls: [
{
index: toolCall.index ?? 0,
id: toolCall.id,
type: toolCall.type ?? "function",
function: toolCall.function,
},
],
},
finish_reason: null,
})
}
}
if (choice.finish_reason) {
result.choices.push({
index: choice.index ?? 0,
delta: {},
finish_reason: choice.finish_reason,
})
}
if (json.usage) {
const usage = json.usage
result.usage = {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
...(usage.prompt_tokens_details?.cached_tokens
? { prompt_tokens_details: { cached_tokens: usage.prompt_tokens_details.cached_tokens } }
: {}),
}
}
return result
}
export function toOaCompatibleChunk(chunk: CommonChunk): string {
const result: any = {
id: chunk.id,
object: "chat.completion.chunk",
created: chunk.created,
model: chunk.model,
choices: [],
}
if (!chunk.choices || chunk.choices.length === 0) {
return `data: ${JSON.stringify(result)}`
}
const choice = chunk.choices[0]
const delta = choice.delta
if (delta?.role) {
result.choices.push({
index: choice.index,
delta: { role: delta.role },
finish_reason: null,
})
}
if (delta?.content) {
result.choices.push({
index: choice.index,
delta: { content: delta.content },
finish_reason: null,
})
}
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
result.choices.push({
index: choice.index,
delta: {
tool_calls: [
{
index: tc.index,
id: tc.id,
type: tc.type,
function: tc.function,
},
],
},
finish_reason: null,
})
}
}
if (choice.finish_reason) {
result.choices.push({
index: choice.index,
delta: {},
finish_reason: choice.finish_reason,
})
}
if (chunk.usage) {
result.usage = {
prompt_tokens: chunk.usage.prompt_tokens,
completion_tokens: chunk.usage.completion_tokens,
total_tokens: chunk.usage.total_tokens,
...(chunk.usage.prompt_tokens_details?.cached_tokens
? {
prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens },
}
: {}),
}
}
return `data: ${JSON.stringify(result)}`
}
@@ -0,0 +1,601 @@
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
type Usage = {
input_tokens?: number
input_tokens_details?: {
cached_tokens?: number
}
output_tokens?: number
output_tokens_details?: {
reasoning_tokens?: number
}
total_tokens?: number
}
export const openaiHelper = {
format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => {
return body
},
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
const [event, data] = chunk.split("\n")
if (event !== "event: response.completed") return
if (!data.startsWith("data: ")) return
let json
try {
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
} catch (e) {
return
}
if (!json.response?.usage) return
usage = json.response.usage
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.input_tokens ?? 0
const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper
export function fromOpenaiRequest(body: any): CommonRequest {
if (!body || typeof body !== "object") return body
const toImg = (p: any) => {
if (!p || typeof p !== "object") return undefined
if ((p as any).type === "image_url" && (p as any).image_url)
return { type: "image_url", image_url: (p as any).image_url }
if ((p as any).type === "input_image" && (p as any).image_url)
return { type: "image_url", image_url: (p as any).image_url }
const s = (p as any).source
if (!s || typeof s !== "object") return undefined
if ((s as any).type === "url" && typeof (s as any).url === "string")
return { type: "image_url", image_url: { url: (s as any).url } }
if (
(s as any).type === "base64" &&
typeof (s as any).media_type === "string" &&
typeof (s as any).data === "string"
)
return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
return undefined
}
const msgs: any[] = []
const inMsgs = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : []
for (const m of inMsgs) {
if (!m) continue
// Responses API items without role:
if (!(m as any).role && (m as any).type) {
if ((m as any).type === "function_call") {
const name = (m as any).name
const a = (m as any).arguments
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
msgs.push({
role: "assistant",
tool_calls: [{ id: (m as any).id, type: "function", function: { name, arguments: args } }],
})
}
if ((m as any).type === "function_call_output") {
const id = (m as any).call_id
const out = (m as any).output
const content = typeof out === "string" ? out : JSON.stringify(out)
msgs.push({ role: "tool", tool_call_id: id, content })
}
continue
}
if ((m as any).role === "system" || (m as any).role === "developer") {
const c = (m as any).content
if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c })
if (Array.isArray(c)) {
const t = c.find((p: any) => p && typeof p.text === "string")
if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text })
}
continue
}
if ((m as any).role === "user") {
const c = (m as any).content
if (typeof c === "string") {
msgs.push({ role: "user", content: c })
} else if (Array.isArray(c)) {
const parts: any[] = []
for (const p of c) {
if (!p || !(p as any).type) continue
if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string")
parts.push({ type: "text", text: (p as any).text })
const ip = toImg(p)
if (ip) parts.push(ip)
if ((p as any).type === "tool_result") {
const id = (p as any).tool_call_id
const content =
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
msgs.push({ role: "tool", tool_call_id: id, content })
}
}
if (parts.length === 1 && parts[0].type === "text") msgs.push({ role: "user", content: parts[0].text })
else if (parts.length > 0) msgs.push({ role: "user", content: parts })
}
continue
}
if ((m as any).role === "assistant") {
const c = (m as any).content
const out: any = { role: "assistant" }
if (typeof c === "string" && c.length > 0) out.content = c
if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls
msgs.push(out)
continue
}
if ((m as any).role === "tool") {
msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content })
continue
}
}
const tcIn = body.tool_choice
const tc = (() => {
if (!tcIn) return undefined
if (tcIn === "auto") return "auto"
if (tcIn === "required") return "required"
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
return { type: "function" as const, function: { name: (tcIn as any).function.name } }
return undefined
})()
const stop = (() => {
const v = body.stop_sequences ?? body.stop
if (!v) return undefined
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
if (typeof v === "string") return v
return undefined
})()
return {
model: body.model,
max_tokens: body.max_output_tokens ?? body.max_tokens,
temperature: body.temperature,
top_p: body.top_p,
stop,
messages: msgs,
stream: !!body.stream,
tools: Array.isArray(body.tools) ? body.tools : undefined,
tool_choice: tc,
}
}
export function toOpenaiRequest(body: CommonRequest) {
if (!body || typeof body !== "object") return body
const msgsIn = Array.isArray(body.messages) ? body.messages : []
const input: any[] = []
const toPart = (p: any) => {
if (!p || typeof p !== "object") return undefined
if ((p as any).type === "text" && typeof (p as any).text === "string")
return { type: "input_text", text: (p as any).text }
if ((p as any).type === "image_url" && (p as any).image_url)
return { type: "input_image", image_url: (p as any).image_url }
const s = (p as any).source
if (!s || typeof s !== "object") return undefined
if ((s as any).type === "url" && typeof (s as any).url === "string")
return { type: "input_image", image_url: { url: (s as any).url } }
if (
(s as any).type === "base64" &&
typeof (s as any).media_type === "string" &&
typeof (s as any).data === "string"
)
return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
return undefined
}
for (const m of msgsIn) {
if (!m || !(m as any).role) continue
if ((m as any).role === "system") {
const c = (m as any).content
if (typeof c === "string") input.push({ role: "system", content: c })
continue
}
if ((m as any).role === "user") {
const c = (m as any).content
if (typeof c === "string") {
input.push({ role: "user", content: [{ type: "input_text", text: c }] })
} else if (Array.isArray(c)) {
const parts: any[] = []
for (const p of c) {
const op = toPart(p)
if (op) parts.push(op)
}
if (parts.length > 0) input.push({ role: "user", content: parts })
}
continue
}
if ((m as any).role === "assistant") {
const c = (m as any).content
if (typeof c === "string" && c.length > 0) {
input.push({ role: "assistant", content: [{ type: "output_text", text: c }] })
}
if (Array.isArray((m as any).tool_calls)) {
for (const tc of (m as any).tool_calls) {
if ((tc as any).type === "function" && (tc as any).function) {
const name = (tc as any).function.name
const a = (tc as any).function.arguments
const args = typeof a === "string" ? a : JSON.stringify(a)
input.push({ type: "function_call", call_id: (tc as any).id, name, arguments: args })
}
}
}
continue
}
if ((m as any).role === "tool") {
const out = typeof (m as any).content === "string" ? (m as any).content : JSON.stringify((m as any).content)
input.push({ type: "function_call_output", call_id: (m as any).tool_call_id, output: out })
continue
}
}
const stop_sequences = (() => {
const v = body.stop
if (!v) return undefined
if (Array.isArray(v)) return v
if (typeof v === "string") return [v]
return undefined
})()
const tcIn = body.tool_choice
const tool_choice = (() => {
if (!tcIn) return undefined
if (tcIn === "auto") return "auto"
if (tcIn === "required") return "required"
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
return { type: "function", function: { name: (tcIn as any).function.name } }
return undefined
})()
const tools = (() => {
if (!Array.isArray(body.tools)) return undefined
return body.tools.map((tool: any) => {
if (tool.type === "function") {
return {
type: "function",
name: tool.function?.name,
description: tool.function?.description,
parameters: tool.function?.parameters,
strict: tool.function?.strict,
}
}
return tool
})
})()
return {
model: body.model,
input,
max_output_tokens: body.max_tokens,
top_p: body.top_p,
stop_sequences,
stream: !!body.stream,
tools,
tool_choice,
include: Array.isArray((body as any).include) ? (body as any).include : undefined,
truncation: (body as any).truncation,
metadata: (body as any).metadata,
store: (body as any).store,
user: (body as any).user,
text: { verbosity: body.model === "gpt-5-codex" ? "medium" : "low" },
reasoning: { effort: "medium" },
}
}
export function fromOpenaiResponse(resp: any): CommonResponse {
if (!resp || typeof resp !== "object") return resp
if (Array.isArray((resp as any).choices)) return resp
const r = (resp as any).response ?? resp
if (!r || typeof r !== "object") return resp
const idIn = (r as any).id
const id =
typeof idIn === "string" ? idIn.replace(/^resp_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
const model = (r as any).model ?? (resp as any).model
const out = Array.isArray((r as any).output) ? (r as any).output : []
const text = out
.filter((o: any) => o && o.type === "message" && Array.isArray((o as any).content))
.flatMap((o: any) => (o as any).content)
.filter((p: any) => p && p.type === "output_text" && typeof p.text === "string")
.map((p: any) => p.text)
.join("")
const tcs = out
.filter((o: any) => o && o.type === "function_call")
.map((o: any) => {
const name = (o as any).name
const a = (o as any).arguments
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
const tid =
typeof (o as any).id === "string" && (o as any).id.length > 0
? (o as any).id
: `toolu_${Math.random().toString(36).slice(2)}`
return { id: tid, type: "function" as const, function: { name, arguments: args } }
})
const finish = (r: string | null) => {
if (r === "stop") return "stop"
if (r === "tool_call" || r === "tool_calls") return "tool_calls"
if (r === "length" || r === "max_output_tokens") return "length"
if (r === "content_filter") return "content_filter"
return null
}
const u = (r as any).usage ?? (resp as any).usage
const usage = (() => {
if (!u) return undefined as any
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
const total = pt != null && ct != null ? pt + ct : undefined
const cached = (u as any).input_tokens_details?.cached_tokens
const details = typeof cached === "number" ? { cached_tokens: cached } : undefined
return {
prompt_tokens: pt,
completion_tokens: ct,
total_tokens: total,
...(details ? { prompt_tokens_details: details } : {}),
}
})()
return {
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: "assistant",
...(text && text.length > 0 ? { content: text } : {}),
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
},
finish_reason: finish((r as any).stop_reason ?? null),
},
],
...(usage ? { usage } : {}),
}
}
export function toOpenaiResponse(resp: CommonResponse) {
if (!resp || typeof resp !== "object") return resp
if (!Array.isArray((resp as any).choices)) return resp
const choice = (resp as any).choices[0]
if (!choice) return resp
const msg = choice.message
if (!msg) return resp
const outputItems: any[] = []
if (typeof msg.content === "string" && msg.content.length > 0) {
outputItems.push({
id: `msg_${Math.random().toString(36).slice(2)}`,
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }],
})
}
if (Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if ((tc as any).type === "function" && (tc as any).function) {
outputItems.push({
id: (tc as any).id,
type: "function_call",
name: (tc as any).function.name,
call_id: (tc as any).id,
arguments: (tc as any).function.arguments,
})
}
}
}
const stop_reason = (() => {
const r = choice.finish_reason
if (r === "stop") return "stop"
if (r === "tool_calls") return "tool_call"
if (r === "length") return "max_output_tokens"
if (r === "content_filter") return "content_filter"
return null
})()
const usage = (() => {
const u = (resp as any).usage
if (!u) return undefined
return {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
...(u.prompt_tokens_details?.cached_tokens
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
: {}),
}
})()
return {
id: (resp as any).id?.replace(/^chatcmpl_/, "resp_") ?? `resp_${Math.random().toString(36).slice(2)}`,
object: "response",
model: (resp as any).model,
output: outputItems,
stop_reason,
usage,
}
}
export function fromOpenaiChunk(chunk: string): CommonChunk | string {
const lines = chunk.split("\n")
const ev = lines[0]
const dl = lines[1]
if (!ev || !dl || !dl.startsWith("data: ")) return chunk
let json: any
try {
json = JSON.parse(dl.slice(6))
} catch {
return chunk
}
const respObj = json.response ?? {}
const out: CommonChunk = {
id: respObj.id ?? json.id ?? "",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: respObj.model ?? json.model ?? "",
choices: [],
}
const e = ev.replace("event: ", "").trim()
if (e === "response.output_text.delta") {
const d = (json as any).delta ?? (json as any).text ?? (json as any).output_text_delta
if (typeof d === "string" && d.length > 0)
out.choices.push({ index: 0, delta: { content: d }, finish_reason: null })
}
if (e === "response.output_item.added" && (json as any).item?.type === "function_call") {
const name = (json as any).item?.name
const id = (json as any).item?.id
if (typeof name === "string" && name.length > 0) {
out.choices.push({
index: 0,
delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
finish_reason: null,
})
}
}
if (e === "response.function_call_arguments.delta") {
const a = (json as any).delta ?? (json as any).arguments_delta
if (typeof a === "string" && a.length > 0) {
out.choices.push({
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: a } }] },
finish_reason: null,
})
}
}
if (e === "response.completed") {
const fr = (() => {
const sr = (respObj as any).stop_reason ?? (json as any).stop_reason
if (sr === "stop") return "stop"
if (sr === "tool_call" || sr === "tool_calls") return "tool_calls"
if (sr === "length" || sr === "max_output_tokens") return "length"
if (sr === "content_filter") return "content_filter"
return null
})()
out.choices.push({ index: 0, delta: {}, finish_reason: fr })
const u = (respObj as any).usage ?? (json as any).response?.usage
if (u) {
out.usage = {
prompt_tokens: u.input_tokens,
completion_tokens: u.output_tokens,
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
...(u.input_tokens_details?.cached_tokens
? { prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens } }
: {}),
}
}
}
return out
}
export function toOpenaiChunk(chunk: CommonChunk): string {
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
return ""
}
const choice = chunk.choices[0]
const d = choice.delta
if (!d) return ""
const id = chunk.id
const model = chunk.model
if (d.content) {
const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } }
return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}`
}
if (d.tool_calls) {
for (const tc of d.tool_calls) {
if (tc.function?.name) {
const data = {
type: "response.output_item.added",
output_index: 0,
item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" },
}
return `event: response.output_item.added\ndata: ${JSON.stringify(data)}`
}
if (tc.function?.arguments) {
const data = {
type: "response.function_call_arguments.delta",
output_index: 0,
delta: tc.function.arguments,
}
return `event: response.function_call_arguments.delta\ndata: ${JSON.stringify(data)}`
}
}
}
if (choice.finish_reason) {
const u = chunk.usage
const usage = u
? {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
...(u.prompt_tokens_details?.cached_tokens
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
: {}),
}
: undefined
const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } }
return `event: response.completed\ndata: ${JSON.stringify(data)}`
}
return ""
}
@@ -0,0 +1,207 @@
import { Format } from "../format"
import {
fromAnthropicChunk,
fromAnthropicRequest,
fromAnthropicResponse,
toAnthropicChunk,
toAnthropicRequest,
toAnthropicResponse,
} from "./anthropic"
import {
fromOpenaiChunk,
fromOpenaiRequest,
fromOpenaiResponse,
toOpenaiChunk,
toOpenaiRequest,
toOpenaiResponse,
} from "./openai"
import {
fromOaCompatibleChunk,
fromOaCompatibleRequest,
fromOaCompatibleResponse,
toOaCompatibleChunk,
toOaCompatibleRequest,
toOaCompatibleResponse,
} from "./openai-compatible"
export type ProviderHelper = {
format: Format
modifyUrl: (providerApi: string) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
createUsageParser: () => {
parse: (chunk: string) => void
retrieve: () => any
}
normalizeUsage: (usage: any) => {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
}
export interface CommonMessage {
role: "system" | "user" | "assistant" | "tool"
content?: string | Array<CommonContentPart>
tool_call_id?: string
tool_calls?: CommonToolCall[]
}
export interface CommonContentPart {
type: "text" | "image_url"
text?: string
image_url?: { url: string }
}
export interface CommonToolCall {
id: string
type: "function"
function: {
name: string
arguments: string
}
}
export interface CommonTool {
type: "function"
function: {
name: string
description?: string
parameters?: Record<string, any>
}
}
export interface CommonUsage {
input_tokens?: number
output_tokens?: number
total_tokens?: number
prompt_tokens?: number
completion_tokens?: number
cache_read_input_tokens?: number
cache_creation?: {
ephemeral_5m_input_tokens?: number
ephemeral_1h_input_tokens?: number
}
input_tokens_details?: {
cached_tokens?: number
}
output_tokens_details?: {
reasoning_tokens?: number
}
}
export interface CommonRequest {
model: string
max_tokens?: number
temperature?: number
top_p?: number
stop?: string | string[]
messages: CommonMessage[]
stream?: boolean
tools?: CommonTool[]
tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } }
}
export interface CommonResponse {
id: string
object: "chat.completion"
created: number
model: string
choices: Array<{
index: number
message: {
role: "assistant"
content?: string
tool_calls?: CommonToolCall[]
}
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
}>
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
prompt_tokens_details?: { cached_tokens?: number }
}
}
export interface CommonChunk {
id: string
object: "chat.completion.chunk"
created: number
model: string
choices: Array<{
index: number
delta: {
role?: "assistant"
content?: string
tool_calls?: Array<{
index: number
id?: string
type?: "function"
function?: {
name?: string
arguments?: string
}
}>
}
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
}>
usage?: {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
prompt_tokens_details?: { cached_tokens?: number }
}
}
export function createBodyConverter(from: Format, to: Format) {
return (body: any): any => {
if (from === to) return body
let raw: CommonRequest
if (from === "anthropic") raw = fromAnthropicRequest(body)
else if (from === "openai") raw = fromOpenaiRequest(body)
else raw = fromOaCompatibleRequest(body)
if (to === "anthropic") return toAnthropicRequest(raw)
if (to === "openai") return toOpenaiRequest(raw)
if (to === "oa-compat") return toOaCompatibleRequest(raw)
}
}
export function createStreamPartConverter(from: Format, to: Format) {
return (part: any): any => {
if (from === to) return part
let raw: CommonChunk | string
if (from === "anthropic") raw = fromAnthropicChunk(part)
else if (from === "openai") raw = fromOpenaiChunk(part)
else raw = fromOaCompatibleChunk(part)
// If result is a string (error case), pass it through
if (typeof raw === "string") return raw
if (to === "anthropic") return toAnthropicChunk(raw)
if (to === "openai") return toOpenaiChunk(raw)
if (to === "oa-compat") return toOaCompatibleChunk(raw)
}
}
export function createResponseConverter(from: Format, to: Format) {
return (response: any): any => {
if (from === to) return response
let raw: CommonResponse
if (from === "anthropic") raw = fromAnthropicResponse(response)
else if (from === "openai") raw = fromOpenaiResponse(response)
else raw = fromOaCompatibleResponse(response)
if (to === "anthropic") return toAnthropicResponse(raw)
if (to === "openai") return toOpenaiResponse(raw)
if (to === "oa-compat") return toOaCompatibleResponse(raw)
}
}
@@ -1,63 +1,9 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/handler"
type Usage = {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
// used by moonshot
cached_tokens?: number
// used by xai
prompt_tokens_details?: {
text_tokens?: number
audio_tokens?: number
image_tokens?: number
cached_tokens?: number
}
completion_tokens_details?: {
reasoning_tokens?: number
audio_tokens?: number
accepted_prediction_tokens?: number
rejected_prediction_tokens?: number
}
}
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
let usage: Usage
return handler(input, {
modifyBody: (body: any) => ({
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
}),
setAuthHeader: (headers: Headers, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
format: "oa-compat",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
onStreamPart: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
} catch (e) {
return
}
if (!json.usage) return
usage = json.usage
},
getStreamUsage: () => usage,
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
}
},
})
}
@@ -1,64 +1,9 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/handler"
type Usage = {
cache_creation?: {
ephemeral_5m_input_tokens?: number
ephemeral_1h_input_tokens?: number
}
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
input_tokens?: number
output_tokens?: number
server_tool_use?: {
web_search_requests?: number
}
}
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
let usage: Usage
return handler(input, {
modifyBody: (body: any) => ({
...body,
service_tier: "standard_only",
}),
setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey),
format: "anthropic",
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
onStreamPart: (chunk: string) => {
const data = chunk.split("\n")[1]
if (!data.startsWith("data: ")) return
let json
try {
json = JSON.parse(data.slice(6))
} catch (e) {
return
}
// ie. { type: "message_start"; message: { usage: Usage } }
// ie. { type: "message_delta"; usage: Usage }
const usageUpdate = json.usage ?? json.message?.usage
if (!usageUpdate) return
usage = {
...usage,
...usageUpdate,
cache_creation: {
...usage?.cache_creation,
...usageUpdate.cache_creation,
},
server_tool_use: {
...usage?.server_tool_use,
...usageUpdate.server_tool_use,
},
}
},
getStreamUsage: () => usage,
normalizeUsage: (usage: Usage) => ({
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
}),
})
}
@@ -0,0 +1,63 @@
import type { APIEvent } from "@solidjs/start/server"
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ZenData } from "@opencode-ai/console-core/model.js"
export async function OPTIONS(input: APIEvent) {
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
})
}
export async function GET(input: APIEvent) {
const zenData = ZenData.list()
const disabledModels = await authenticate()
return new Response(
JSON.stringify({
object: "list",
data: Object.entries(zenData.models)
.filter(([id]) => !disabledModels.includes(id))
.map(([id, _model]) => ({
id,
object: "model",
created: Math.floor(Date.now() / 1000),
owned_by: "opencode",
})),
}),
{
headers: {
"Content-Type": "application/json",
},
},
)
async function authenticate() {
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
if (!apiKey) return []
const disabledModels = await Database.use((tx) =>
tx
.select({
model: ModelTable.model,
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.leftJoin(
ModelTable,
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows.map((row) => row.model)),
)
return disabledModels
}
}
@@ -1,52 +1,9 @@
import type { APIEvent } from "@solidjs/start/server"
import { handler } from "~/routes/zen/handler"
type Usage = {
input_tokens?: number
input_tokens_details?: {
cached_tokens?: number
}
output_tokens?: number
output_tokens_details?: {
reasoning_tokens?: number
}
total_tokens?: number
}
import { handler } from "~/routes/zen/util/handler"
export function POST(input: APIEvent) {
let usage: Usage
return handler(input, {
setAuthHeader: (headers: Headers, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
format: "openai",
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
onStreamPart: (chunk: string) => {
const [event, data] = chunk.split("\n")
if (event !== "event: response.completed") return
if (!data.startsWith("data: ")) return
let json
try {
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
} catch (e) {
return
}
if (!json.response?.usage) return
usage = json.response.usage
},
getStreamUsage: () => usage,
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.input_tokens ?? 0
const outputTokens = usage.output_tokens ?? 0
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
return {
inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens: outputTokens - (reasoningTokens ?? 0),
reasoningTokens,
cacheReadTokens,
}
},
})
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "0.15.17",
"version": "0.15.30",
"private": true,
"type": "module",
"dependencies": {
@@ -0,0 +1,33 @@
import { Database, eq } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql"
// get input from command line
const email = process.argv[2]
if (!email) {
console.error("Usage: bun lookup-user.ts <email>")
process.exit(1)
}
const authData = await printTable("Auth", (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.subject, email)),
)
if (authData.length === 0) {
console.error("User not found")
process.exit(1)
}
await printTable("Auth", (tx) =>
tx.select().from(AuthTable).where(eq(AuthTable.accountID, authData[0].accountID)),
)
function printTable(
title: string,
callback: (tx: Database.TxOrDb) => Promise<any[]>,
): Promise<any[]> {
return Database.use(async (tx) => {
const data = await callback(tx)
console.log(`== ${title} ==`)
console.table(data)
return data
})
}
+15 -7
View File
@@ -1,13 +1,21 @@
import { Resource } from "@opencode-ai/console-resource"
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { Database } from "../src/drizzle/index.js"
import { UserTable } from "../src/schema/user.sql.js"
import { AccountTable } from "../src/schema/account.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "../src/schema/billing.sql.js"
import { KeyTable } from "../src/schema/key.sql.js"
if (Resource.App.stage !== "frank") throw new Error("This script is only for frank")
for (const table of [AccountTable, BillingTable, KeyTable, PaymentTable, UsageTable, UserTable, WorkspaceTable]) {
for (const table of [
AccountTable,
BillingTable,
KeyTable,
PaymentTable,
UsageTable,
UserTable,
WorkspaceTable,
]) {
await Database.use((tx) => tx.delete(table))
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "0.15.17",
"version": "0.15.30",
"$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": "0.15.17",
"version": "0.15.30",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
<title>OpenCode</title>
</head>
<body class="overscroll-none select-none text-12-regular">
<body class="antialiased overscroll-none select-none text-12-regular">
<!-- <script> -->
<!-- ;(function () { -->
<!-- const savedTheme = localStorage.getItem("theme") || "opencode" -->
+4 -5
View File
@@ -1,14 +1,14 @@
{
"name": "@opencode-ai/desktop",
"version": "0.15.17",
"version": "0.15.30",
"description": "",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"typecheck": "tsgo --noEmit"
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
@@ -16,8 +16,8 @@
"@tsconfig/bun": "1.0.9",
"@types/luxon": "3.7.1",
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:"
@@ -26,7 +26,6 @@
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/event-bus": "1.1.2",
+1 -1
View File
@@ -2,7 +2,7 @@ import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "s
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
import { useLocal, type TextSelection } from "@/context/local"
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
import { useShiki } from "@/context/shiki"
import { useShiki } from "@opencode-ai/ui"
type DefinedSelection = Exclude<TextSelection, undefined>
@@ -1,23 +0,0 @@
import { useMarked } from "@/context/marked"
import { createResource } from "solid-js"
function strip(text: string): string {
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
const match = text.match(wrappedRe)
return match ? match[2] : text
}
export function Markdown(props: { text: string; class?: string }) {
const marked = useMarked()
const [html] = createResource(
() => strip(props.text),
async (markdown) => {
return marked.parse(markdown)
},
)
return (
<div
class={`min-w-0 max-w-full overflow-auto no-scrollbar text-14-regular text-text-base ${props.class ?? ""}`}
innerHTML={html()}
/>
)
}
@@ -1,48 +0,0 @@
import { Component, createMemo } from "solid-js"
interface ProgressCircleProps {
percentage: number
size?: number
strokeWidth?: number
}
export const ProgressCircle: Component<ProgressCircleProps> = (props) => {
// --- Set default values for props ---
const size = () => props.size || 16
const strokeWidth = () => props.strokeWidth || 3
// --- Constants for SVG calculation ---
const viewBoxSize = 16
const center = viewBoxSize / 2
const radius = () => center - strokeWidth() / 2
const circumference = createMemo(() => 2 * Math.PI * radius())
// --- Reactive Calculation for the progress offset ---
const offset = createMemo(() => {
const clampedPercentage = Math.max(0, Math.min(100, props.percentage || 0))
const progress = clampedPercentage / 100
return circumference() * (1 - progress)
})
return (
<svg
width={size()}
height={size()}
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
fill="none"
class="transform -rotate-90"
>
<circle cx={center} cy={center} r={radius()} class="stroke-border-weak-base" stroke-width={strokeWidth()} />
<circle
cx={center}
cy={center}
r={radius()}
class="stroke-border-active"
stroke-width={strokeWidth()}
stroke-dasharray={circumference().toString()}
stroke-dashoffset={offset()}
style={{ transition: "stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1)" }}
/>
</svg>
)
}
@@ -10,13 +10,15 @@ import { DateTime } from "luxon"
interface PartBase {
content: string
start: number
end: number
}
interface TextPart extends PartBase {
export interface TextPart extends PartBase {
type: "text"
}
interface FileAttachmentPart extends PartBase {
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
@@ -34,7 +36,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal()
let editorRef!: HTMLDivElement
const defaultParts = [{ type: "text", content: "" } as const]
const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
const [store, setStore] = createStore<{
contentParts: ContentPart[]
popoverIsOpen: boolean
@@ -51,7 +53,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.stopPropagation()
// @ts-expect-error
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText })
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
onMount(() => {
@@ -69,16 +71,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
})
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList<string>({
items: local.file.search,
key: (x) => x,
onSelect: (path) => {
if (!path) return
addPart({ type: "file", path, content: "@" + getFilename(path) })
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
setStore("popoverIsOpen", false)
},
})
createEffect(() => {
local.model.recent()
refetch()
})
createEffect(
on(
() => store.contentParts,
@@ -117,17 +124,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const parseFromDOM = (): ContentPart[] => {
const newParts: ContentPart[] = []
let position = 0
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
if (node.textContent) {
const content = node.textContent
newParts.push({ type: "text", content, start: position, end: position + content.length })
position += content.length
}
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
switch ((node as HTMLElement).dataset.type) {
case "file":
const content = node.textContent!
newParts.push({
type: "file",
path: (node as HTMLElement).dataset.path!,
content: node.textContent!,
content,
start: position,
end: position + content.length,
})
position += content.length
break
default:
break
@@ -163,17 +179,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const startIndex = atMatch ? atMatch.index! : cursorPosition
const endIndex = atMatch ? cursorPosition : cursorPosition
const pushText = (acc: { parts: ContentPart[] }, value: string) => {
const pushText = (acc: { parts: ContentPart[]; runningIndex: number }, value: string) => {
if (!value) return
const last = acc.parts[acc.parts.length - 1]
if (last && last.type === "text") {
acc.parts[acc.parts.length - 1] = {
type: "text",
content: last.content + value,
start: last.start,
end: last.end + value.length,
}
return
}
acc.parts.push({ type: "text", content: value })
acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length })
}
const {
@@ -183,20 +201,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} = store.contentParts.reduce(
(acc, item) => {
if (acc.inserted) {
acc.parts.push(item)
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex += item.content.length
return acc
}
const nextIndex = acc.runningIndex + item.content.length
if (nextIndex <= startIndex) {
acc.parts.push(item)
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex
return acc
}
if (item.type !== "text") {
acc.parts.push(item)
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
acc.runningIndex = nextIndex
return acc
}
@@ -207,24 +225,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const tail = item.content.slice(tailLength)
pushText(acc, head)
acc.runningIndex += head.length
if (part.type === "text") {
pushText(acc, part.content)
acc.runningIndex += part.content.length
}
if (part.type !== "text") {
acc.parts.push({ ...part })
acc.parts.push({ ...part, start: acc.runningIndex, end: acc.runningIndex + part.content.length })
acc.runningIndex += part.content.length
}
const needsGap = Boolean(atMatch)
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
pushText(acc, rest)
acc.runningIndex += rest.length
const baseCursor = startIndex + part.content.length
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
acc.cursorPositionAfter = baseCursor + cursorAddition
acc.inserted = true
acc.runningIndex = nextIndex
return acc
},
{
@@ -237,9 +258,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!inserted) {
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
const appendedAcc = { parts: [...baseParts] as ContentPart[] }
if (part.type === "text") pushText(appendedAcc, part.content)
if (part.type !== "text") appendedAcc.parts.push({ ...part })
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
if (part.type === "text") {
pushText(appendedAcc, part.content)
}
if (part.type !== "text") {
appendedAcc.parts.push({
...part,
start: appendedAcc.runningIndex,
end: appendedAcc.runningIndex + part.content.length,
})
}
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
setStore("contentParts", next)
setStore("popoverIsOpen", false)
@@ -289,7 +319,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}/
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
@@ -344,16 +374,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
items={local.model.list()}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
groupBy={(x) => x.provider.name}
groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)}
sortGroupsBy={(a, b) => {
const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (order.includes(aProvider) && !order.includes(bProvider)) return -1
if (!order.includes(aProvider) && order.includes(bProvider)) return 1
return order.indexOf(aProvider) - order.indexOf(bProvider)
}}
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
onSelect={(x) =>
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true })
}
trigger={
<Button as="div" variant="ghost">
{local.model.current()?.name ?? "Select model"}
@@ -368,9 +402,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
<Show when={i.release_date}>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
</Show>
</div>
</div>
<Show when={!i.cost || i.cost?.input === 0}>
@@ -1,536 +0,0 @@
import { Icon, Tooltip } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {
createSignal,
For,
Match,
splitProps,
Switch,
type ComponentProps,
type ParentProps,
createEffect,
createMemo,
Show,
} from "solid-js"
import { getFilename } from "@/utils"
import { Markdown } from "./markdown"
import { Code } from "./code"
import { createElementSize } from "@solid-primitives/resize-observer"
import { createScrollPosition } from "@solid-primitives/scroll"
import { ProgressCircle } from "./progress-circle"
import { pipe, sumBy } from "remeda"
import { useSync } from "@/context/sync"
import { useLocal } from "@/context/local"
function Part(props: ParentProps & ComponentProps<"div">) {
const [local, others] = splitProps(props, ["class", "classList", "children"])
return (
<div
classList={{
...(local.classList ?? {}),
"h-6 flex items-center": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
>
<p class="text-12-medium text-left">{local.children}</p>
</div>
)
}
function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
return (
<Collapsible {...props}>
<Collapsible.Trigger class="peer/collapsible">
<Part>{props.title}</Part>
</Collapsible.Trigger>
<Collapsible.Content>
<p class="flex-auto min-w-0 text-pretty">
<span class="text-12-medium text-text-weak break-words">{props.children}</span>
</p>
</Collapsible.Content>
</Collapsible>
)
}
function ReadToolPart(props: { part: ToolPart }) {
const sync = useSync()
const local = useLocal()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Reading file...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => {
const path = state().input["filePath"] as string
return (
<Part onClick={() => local.file.open(path)}>
<span class="">Read</span> {getFilename(path)}
</Part>
)
}}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<div>
<Part>
<span class="">Read</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
</Switch>
)
}
function EditToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Preparing edit...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
<Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
</Switch>
)
}
function WriteToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Preparing write...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
</>
}
>
<div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<div>
<Part>
<span class="">Write</span> {getFilename(state().input["filePath"] as string)}
</Part>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</div>
)}
</Match>
</Switch>
)
}
function BashToolPart(props: { part: ToolPart }) {
const sync = useSync()
return (
<Switch>
<Match when={props.part.state.status === "pending"}>
<Part>Writing shell command...</Part>
</Match>
<Match when={props.part.state.status === "completed" && props.part.state}>
{(state) => (
<CollapsiblePart
defaultOpen
title={
<>
<span class="">Run command:</span> {state().input["command"]}
</>
}
>
<Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
</CollapsiblePart>
)}
</Match>
<Match when={props.part.state.status === "error" && props.part.state}>
{(state) => (
<CollapsiblePart
title={
<>
<span class="">Shell</span> {state().input["command"]}
</>
}
>
<div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
</CollapsiblePart>
)}
</Match>
</Switch>
)
}
function ToolPart(props: { part: ToolPart }) {
// read
// edit
// write
// bash
// ls
// glob
// grep
// todowrite
// todoread
// webfetch
// websearch
// patch
// task
return (
<div class="min-w-0 flex-auto text-12-medium">
<Switch
fallback={
<span>
{props.part.type}:{props.part.tool}
</span>
}
>
<Match when={props.part.tool === "read"}>
<ReadToolPart part={props.part} />
</Match>
<Match when={props.part.tool === "edit"}>
<EditToolPart part={props.part} />
</Match>
<Match when={props.part.tool === "write"}>
<WriteToolPart part={props.part} />
</Match>
<Match when={props.part.tool === "bash"}>
<BashToolPart part={props.part} />
</Match>
</Switch>
</div>
)
}
export default function SessionTimeline(props: { session: string; class?: string }) {
const sync = useSync()
const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
const [tail, setTail] = createSignal(true)
const size = createElementSize(root)
const scroll = createScrollPosition(scrollElement)
const valid = (part: Part) => {
if (!part) return false
switch (part.type) {
case "step-start":
case "step-finish":
case "file":
case "patch":
return false
case "text":
return !part.synthetic && part.text.trim()
case "reasoning":
return part.text.trim()
case "tool":
switch (part.tool) {
case "todoread":
case "todowrite":
case "list":
case "grep":
return false
}
return true
default:
return true
}
}
const hasValidParts = (message: Message) => {
return sync.data.part[message.id]?.filter(valid).length > 0
}
const hasTextPart = (message: Message) => {
return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
}
const session = createMemo(() => sync.session.get(props.session))
const messages = createMemo(() => sync.data.message[props.session] ?? [])
const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
if (last.role === "user") return true
return !last.time.completed
})
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const last = createMemo(() => {
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
return model
})
const tokens = createMemo(() => {
if (!last()) return
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(total)
})
const context = createMemo(() => {
if (!last()) return
if (!model()?.limit.context) return 0
const tokens = last().tokens
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
return Math.round((total / model()!.limit.context) * 100)
})
const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
let p = el?.parentElement
while (p && p !== document.body) {
const s = getComputedStyle(p)
if (s.overflowY === "auto" || s.overflowY === "scroll") return p
p = p.parentElement
}
return undefined
}
createEffect(() => {
if (!root()) return
setScrollElement(getScrollParent(root()!))
})
const scrollToBottom = () => {
const element = scrollElement()
if (!element) return
element.scrollTop = element.scrollHeight
}
createEffect(() => {
size.height
if (tail()) scrollToBottom()
})
createEffect(() => {
if (working()) {
setTail(true)
scrollToBottom()
}
})
let lastScrollY = 0
createEffect(() => {
if (scroll.y < lastScrollY) {
setTail(false)
}
lastScrollY = scroll.y
})
const duration = (part: Part) => {
switch (part.type) {
default:
if (
"time" in part &&
part.time &&
"start" in part.time &&
part.time.start &&
"end" in part.time &&
part.time.end
) {
const start = DateTime.fromMillis(part.time.start)
const end = DateTime.fromMillis(part.time.end)
return end.diff(start).toFormat("s")
}
return ""
}
}
createEffect(() => {
console.log("WHAT")
console.log(JSON.stringify(messagesWithValidParts()))
})
return (
<div
ref={setRoot}
classList={{
"select-text flex flex-col text-text-weak": true,
[props.class ?? ""]: !!props.class,
}}
>
<div class="flex justify-end items-center self-stretch">
<div class="flex items-center gap-6">
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
<Show when={context()}>
<ProgressCircle percentage={context()!} />
</Show>
<div class="text-14-regular text-text-weak text-right">{context()}%</div>
</Tooltip>
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
</div>
</div>
<ul role="list" class="flex flex-col items-start self-stretch">
<For each={messagesWithValidParts()}>
{(message) => (
<div
classList={{
"flex flex-col gap-1 justify-center items-start self-stretch": true,
"mt-6": hasTextPart(message),
}}
>
<For each={sync.data.part[message.id]?.filter(valid) ?? []}>
{(part) => (
<li class="group/li">
<Switch fallback={<div class="">{part.type}</div>}>
<Match when={part.type === "text" && part}>
{(part) => (
<Switch>
<Match when={message.role === "user"}>
<div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
<span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
{part().text}
</span>
</div>
</Match>
<Match when={message.role === "assistant"}>
<Markdown text={sync.sanitize(part().text)} />
</Match>
</Switch>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(part) => (
<CollapsiblePart
title={
<Switch fallback={<span class="text-text-weak">Thinking</span>}>
<Match when={part().time.end}>
<span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
</Match>
</Switch>
}
>
<Markdown text={part().text} />
</CollapsiblePart>
)}
</Match>
<Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
</Switch>
</li>
)}
</For>
</div>
)}
</For>
</ul>
<Show when={false}>
<Collapsible defaultOpen={false}>
<Collapsible.Trigger>
<div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
<Icon name="file-code" />
<span>Raw Session Data</span>
<Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content class="mt-5">
<ul role="list" class="space-y-2">
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" />
<span>session</span>
<Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code path="session.json" code={JSON.stringify(session(), null, 2)} />
</Collapsible.Content>
</Collapsible>
</li>
<For each={messages()}>
{(message) => (
<>
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" />
<span>{message.role === "user" ? "user" : "assistant"}</span>
<Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
</Collapsible.Content>
</Collapsible>
</li>
<For each={sync.data.part[message.id]}>
{(part) => (
<li>
<Collapsible>
<Collapsible.Trigger>
<div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
<Icon name="file-code" />
<span>{part.type}</span>
<Collapsible.Arrow class="text-text-muted" />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
</Collapsible.Content>
</Collapsible>
</li>
)}
</For>
</>
)}
</For>
</ul>
</Collapsible.Content>
</Collapsible>
</Show>
</div>
)
}
+71 -33
View File
@@ -45,6 +45,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
function isModelValid(model: ModelKey) {
const provider = sync.data.provider.find((x) => x.id === model.providerID)
return !!provider?.models[model.modelID]
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
for (const modelFn of modelFns) {
const model = modelFn()
if (!model) continue
if (isModelValid(model)) return model
}
}
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
// else
// toast.show({
// type: "warning",
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
// duration: 3000,
// })
}
})
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [store, setStore] = createStore<{
@@ -76,11 +107,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const [store, setStore] = createStore<{
model: Record<string, ModelKey>
recent: ModelKey[]
@@ -95,27 +121,54 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
localStorage.setItem("model", JSON.stringify(store.recent))
})
const fallback = createMemo(() => {
if (store.recent.length) return store.recent[0]
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
for (const item of store.recent) {
if (isModelValid(item)) {
return item
}
}
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return { modelID: model.id, providerID: provider.id }
return {
providerID: provider.id,
modelID: model.id,
}
})
const current = createMemo(() => {
const currentModel = createMemo(() => {
const a = agent.current()
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
const key = getFirstValidModel(
() => store.model[a.name],
() => a.model,
fallbackModel,
)!
return find(key)
})
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
return {
list,
current,
current: currentModel,
recent,
list,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model ?? fallback())
setStore("model", agent.current().name, model ?? fallbackModel())
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
@@ -279,10 +332,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
break
case "file.watcher.updated":
setTimeout(sync.load.changes, 1000)
const relativePath = relative(event.properties.file)
if (relativePath.startsWith(".git/")) return
load(relativePath)
// setTimeout(sync.load.changes, 1000)
// const relativePath = relative(event.properties.file)
// if (relativePath.startsWith(".git/")) return
// load(relativePath)
break
}
})
@@ -429,13 +482,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.sort((a, b) => b.id.localeCompare(a.id)),
)
const working = createMemo(() => {
const last = messages()[messages().length - 1]
if (!last) return false
if (last.role === "user") return true
return !last.time.completed
})
const cost = createMemo(() => {
const total = pipe(
messages(),
@@ -460,13 +506,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
})
const activeAssistantMessages = createMemo(() => {
if (!store.active || !activeMessage()) return []
return sync.data.message[store.active]?.filter(
(m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
)
})
const model = createMemo(() => {
if (!last()) return
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
@@ -504,7 +543,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return {
active,
activeMessage,
activeAssistantMessages,
lastUserMessage,
cost,
last,
@@ -514,7 +552,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
messages,
messagesWithValidParts,
userMessages,
working,
// working,
getMessageText,
setActive(sessionId: string | undefined) {
setStore("active", sessionId)
+28 -7
View File
@@ -77,21 +77,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
const parts = store.part[event.properties.part.messageID]
const part = sanitizePart(event.properties.part)
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
event.properties.part.messageID,
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.part)
draft.splice(result.index, 0, part)
}),
)
break
@@ -121,6 +122,23 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
const sanitize = (text: string) => text.replace(sanitizer(), "")
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const sanitizePart = (part: Part) => {
if (part.type === "tool") {
if (part.state.status === "completed") {
for (const key in part.state.metadata) {
if (typeof part.state.metadata[key] === "string") {
part.state.metadata[key] = sanitize(part.state.metadata[key] as string)
}
}
for (const key in part.state.input) {
if (typeof part.state.input[key] === "string") {
part.state.input[key] = sanitize(part.state.input[key] as string)
}
}
}
}
return part
}
return {
data: store,
@@ -155,7 +173,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
draft.part[message.info.id] = message.parts
.slice()
.map(sanitizePart)
.sort((a, b) => a.id.localeCompare(b.id))
}
}),
)
+1 -3
View File
@@ -3,9 +3,7 @@ import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Fonts } from "@opencode-ai/ui"
import { ShikiProvider } from "./context/shiki"
import { MarkedProvider } from "./context/marked"
import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
+233 -138
View File
@@ -1,8 +1,23 @@
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
import {
Button,
List,
SelectDialog,
Tooltip,
IconButton,
Tabs,
Icon,
Accordion,
Diff,
Collapsible,
Part,
DiffChanges,
ProgressCircle,
Message,
} from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { getDirectory, getFilename } from "@/utils"
import { ContentPart, PromptInput } from "@/components/prompt-input"
@@ -21,7 +36,8 @@ import type { JSX } from "solid-js"
import { Code } from "@/components/code"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Diff } from "@/components/diff"
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
import { Markdown } from "@opencode-ai/ui"
export default function Page() {
const local = useLocal()
@@ -92,7 +108,7 @@ export default function Page() {
}
}
if (event.key.length === 1 && event.key !== "Unidentified") {
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
@@ -164,6 +180,8 @@ export default function Page() {
}
const handleDiffTriggerClick = (event: MouseEvent) => {
// disabling scroll to diff for now
return
const target = event.currentTarget as HTMLElement
queueMicrotask(() => {
if (target.getAttribute("aria-expanded") !== "true") return
@@ -182,42 +200,11 @@ export default function Page() {
}
if (!session) return
interface SubmissionAttachment {
path: string
selection?: TextSelection
label: string
}
const createAttachmentKey = (path: string, selection?: TextSelection) => {
if (!selection) return path
return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
}
const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
if (!selection) return getFilename(path)
return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
}
local.session.setActive(session.id)
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const text = parts.map((part) => part.content).join("")
const attachments = new Map<string, SubmissionAttachment>()
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
if (!path) return
const key = createAttachmentKey(path, selection)
if (attachments.has(key)) return
attachments.set(key, {
path,
selection,
label: label ?? formatAttachmentLabel(path, selection),
})
}
const promptAttachments = parts.filter((part) => part.type === "file")
for (const part of promptAttachments) {
registerAttachment(part.path, part.selection, part.content)
}
const attachments = parts.filter((part) => part.type === "file")
// const activeFile = local.context.active()
// if (activeFile) {
@@ -236,7 +223,7 @@ export default function Page() {
// )
// }
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
const attachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -249,9 +236,9 @@ export default function Page() {
source: {
type: "file" as const,
text: {
value: `@${attachment.label}`,
start: 0,
end: 0,
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path: absolute,
},
@@ -275,7 +262,6 @@ export default function Page() {
],
},
})
local.session.setActive(session.id)
}
const handleNewSession = () => {
@@ -286,7 +272,7 @@ export default function Page() {
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon node={props.file} class="_grayscale-100" />
<FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
<span
classList={{
"text-14-medium": true,
@@ -325,15 +311,21 @@ export default function Page() {
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<Tooltip value={props.file.path} placement="bottom" class="h-full">
<div class="relative h-full">
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
<Tabs.Trigger
value={props.file.path}
class="group/tab pl-3 pr-1"
onClick={() => props.onTabClick(props.file)}
>
<TabVisual file={props.file} />
<IconButton
icon="close"
class="mt-0.5 opacity-0 text-text-muted/60 group-data-[selected]/tab:opacity-100
group-data-[selected]/tab:text-text group-data-[selected]/tab:hover:bg-border-subtle
hover:opacity-100 group-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.file)}
/>
</Tabs.Trigger>
<IconButton
icon="close"
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
variant="ghost"
onClick={() => props.onTabClose(props.file)}
/>
</div>
</Tooltip>
</div>
@@ -392,9 +384,7 @@ export default function Page() {
{(session) => {
const diffs = createMemo(() => session.summary?.diffs ?? [])
const filesChanged = createMemo(() => diffs().length)
const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0))
const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0))
const updated = DateTime.fromMillis(session.time.updated)
return (
<Tooltip placement="right" value={session.title}>
<div>
@@ -403,17 +393,19 @@ export default function Page() {
{session.title}
</span>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{DateTime.fromMillis(session.time.updated).toRelative()}
{Math.abs(updated.diffNow().as("seconds")) < 60
? "Now"
: updated
.toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</div>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
<Show when={additions() || deletions()}>
<div class="flex gap-2 justify-end items-center">
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
</div>
</Show>
<DiffChanges diff={diffs()} />
</div>
</div>
</Tooltip>
@@ -434,13 +426,12 @@ export default function Page() {
<Tabs onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
<div>Chat</div>
<Show when={local.session.active()}>
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
{local.session.context()}%
</div>
</Show>
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
<ProgressCircle percentage={local.session.context() ?? 0} />
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
</Tooltip>
</Tabs.Trigger>
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
@@ -516,17 +507,17 @@ export default function Page() {
</Show>
</div>
</div>
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
<div class="p-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div class="relative px-6 pt-12 max-w-2xl w-full mx-auto flex flex-col flex-1 min-h-0">
<Show
when={local.session.active()}
fallback={
<div class="flex flex-col pb-36 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
<div class="flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
<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)}/
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
@@ -543,100 +534,104 @@ export default function Page() {
}
>
{(activeSession) => (
<div class="py-3 flex flex-col flex-1 min-h-0">
<div class="flex items-start gap-8 flex-1 min-h-0">
<div class="pt-3 flex flex-col flex-1 min-h-0">
<div class="flex-1 min-h-0">
<Show when={local.session.userMessages().length > 1}>
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
<ul
role="list"
class="absolute right-full mr-8 hidden w-60 shrink-0 @7xl:flex flex-col items-start gap-1"
>
<For each={local.session.userMessages()}>
{(message) => (
<li
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
>
<div class="w-[18px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
<g>
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
</g>
</svg>
</div>
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
{(message) => {
const diffs = createMemo(() => message.summary?.diffs ?? [])
return (
<li
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
onClick={() => local.session.setActiveMessage(message.id)}
>
{message.summary?.title ?? local.session.getMessageText(message)}
</div>
</li>
)}
<DiffChanges diff={diffs()} variant="bars" />
<div
data-active={local.session.activeMessage()?.id === message.id}
classList={{
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
}}
>
{message.summary?.title ?? local.session.getMessageText(message)}
</div>
</li>
)
}}
</For>
</ul>
</Show>
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
<div class="flex flex-col items-start gap-50 pb-[800px]">
<div class="flex flex-col items-start gap-50 pb-50">
<For each={local.session.userMessages()}>
{(message) => {
const title = createMemo(() => message.summary?.title)
const [expanded, setExpanded] = createSignal(false)
const parts = createMemo(() => sync.data.part[message.id])
const prompt = createMemo(() => local.session.getMessageText(message))
const title = createMemo(() => message.summary?.title)
const summary = createMemo(() => message.summary?.body)
const assistantMessages = createMemo(() => {
return sync.data.message[activeSession().id]?.filter(
(m) => m.role === "assistant" && m.parentID == message.id,
) as AssistantMessageType[]
})
const working = createMemo(() => !summary())
return (
<div
data-message={message.id}
class="flex flex-col items-start self-stretch gap-14 pt-1.5"
class="flex flex-col items-start self-stretch gap-8 min-h-screen"
>
{/* Title */}
<div class="flex flex-col items-start gap-2 self-stretch">
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
{title() ?? prompt()}
</h1>
<Show when={title}>
<div class="text-12-regular text-text-base">{prompt()}</div>
</Show>
</div>
<Show when={title}>
<div class="-mt-8">
<Message message={message} parts={parts()} />
</div>
</Show>
{/* Summary */}
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<Show when={summary}>
<Show when={!working()}>
<div class="w-full flex flex-col gap-6 items-start self-stretch">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Summary</h2>
<div class="text-14-regular text-text-base self-stretch">{summary()}</div>
<Show when={summary()}>
<Markdown text={summary()!} />
</Show>
</div>
</Show>
<Show when={message.summary?.diffs.length}>
<Accordion class="w-full" multiple>
<For each={message.summary?.diffs || []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<Accordion.Header>
<Accordion.Trigger onClick={handleDiffTriggerClick}>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-5">
<div class="flex items-center justify-between w-full gap-5">
<div class="grow flex items-center gap-5 min-w-0">
<FileIcon
node={{ path: diff.file, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex">
<div class="flex grow min-w-0">
<Show when={diff.file.includes("/")}>
<span class="text-text-base">
{getDirectory(diff.file)}/
<span class="text-text-base truncate-start">
{getDirectory(diff.file)}&lrm;
</span>
</Show>
<span class="text-text-strong">
<span class="text-text-strong shrink-0">
{getFilename(diff.file)}
</span>
</div>
</div>
<div class="flex gap-4 items-center justify-end">
<div class="flex gap-2 justify-end items-center">
<span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
</div>
<div class="shrink-0 flex gap-4 items-center justify-end">
<DiffChanges diff={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
@@ -658,13 +653,120 @@ export default function Page() {
)}
</For>
</Accordion>
</Show>
</div>
{/* Response */}
<div data-todo="Response (Timeline)">
<div class="flex flex-col items-start gap-1 self-stretch">
<h2 class="text-12-medium text-text-weak">Response</h2>
</div>
</Show>
{/* Response */}
<div class="w-full">
<Switch>
<Match when={working()}>
{(_) => {
const items = createMemo(() =>
assistantMessages().flatMap((m) => sync.data.part[m.id]),
)
const finishedItems = createMemo(() =>
items().filter(
(p) =>
(p?.type === "text" && p.time?.end) ||
(p?.type === "reasoning" && p.time?.end) ||
(p?.type === "tool" && p.state.status === "completed"),
),
)
const MINIMUM_DELAY = 800
const [visibleCount, setVisibleCount] = createSignal(1)
createEffect(() => {
const total = finishedItems().length
if (total > visibleCount()) {
const timer = setTimeout(() => {
setVisibleCount((prev) => prev + 1)
}, MINIMUM_DELAY)
onCleanup(() => clearTimeout(timer))
} else if (total < visibleCount()) {
setVisibleCount(total)
}
})
const translateY = createMemo(() => {
const total = visibleCount()
if (total < 2) return "0px"
return `-${(total - 2) * 48 - 8}px`
})
return (
<div class="flex flex-col gap-3">
<div
class="h-36 overflow-hidden pointer-events-none
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
>
<div
class="w-full flex flex-col items-start self-stretch gap-2 py-10
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ transform: `translateY(${translateY()})` }}
>
<For each={finishedItems()}>
{(part) => {
const message = createMemo(() =>
sync.data.message[part.sessionID].find(
(m) => m.id === part.messageID,
),
)
return (
<div class="h-10 flex items-center w-full">
<Switch>
<Match when={part.type === "text" && part}>
{(p) => (
<div
textContent={p().text}
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
/>
)}
</Match>
<Match when={part.type === "reasoning" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
<Match when={part.type === "tool" && part}>
{(p) => <Part message={message()!} part={p()} />}
</Match>
</Switch>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}}
</Match>
<Match when={!working()}>
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
<div class="flex items-center gap-1 self-stretch">
<div class="text-12-medium">
<Switch>
<Match when={expanded()}>Hide steps</Match>
<Match when={!expanded()}>Show steps</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="w-full flex flex-col items-start self-stretch gap-3">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(
() => sync.data.part[assistantMessage.id],
)
return <Message message={assistantMessage} parts={parts()} />
}}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
)
@@ -699,21 +801,14 @@ export default function Page() {
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
<div
classList={{
"absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
"bottom-8": true,
// "bottom-8": !!local.session.active(),
// "bottom-1/2 translate-y-1/2": !local.session.active(),
}}
>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
@@ -772,7 +867,7 @@ export default function Page() {
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}/
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
+5 -1
View File
@@ -1,3 +1,5 @@
import { useSync } from "@/context/sync"
export function getFilename(path: string) {
if (!path) return ""
const trimmed = path.replace(/[\/]+$/, "")
@@ -6,8 +8,10 @@ export function getFilename(path: string) {
}
export function getDirectory(path: string) {
const sync = useSync()
const parts = path.split("/")
return parts.slice(0, parts.length - 1).join("/")
const dir = parts.slice(0, parts.length - 1).join("/")
return dir ? sync.sanitize(dir + "/") : ""
}
export function getFileExtension(path: string) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "0.15.17",
"version": "0.15.30",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.15.17",
"version": "0.15.30",
"name": "opencode",
"type": "module",
"private": true,
@@ -20,6 +20,10 @@
"@ai-sdk/amazon-bedrock": "2.2.10",
"@ai-sdk/google-vertex": "3.0.16",
"@octokit/webhooks-types": "7.6.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"@standard-schema/spec": "1.0.0",
"@tsconfig/bun": "catalog:",
+6 -1
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bun
import path from "path"
const dir = new URL("..", import.meta.url).pathname
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
import { $ } from "bun"
+30 -9
View File
@@ -40,16 +40,35 @@ for (const [name] of Object.entries(binaries)) {
}
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${Script.channel}`
if (!Script.preview) {
const major = Script.version.split(".")[0]
const majorTag = `latest-${major}`
for (const [name] of Object.entries(binaries)) {
await $`cd dist/${name} && npm dist-tag add ${name}@${Script.version} ${majorTag}`
}
await $`cd ./dist/${pkg.name} && npm dist-tag add ${pkg.name}-ai@${Script.version} ${majorTag}`
}
if (!Script.preview) {
for (const key of Object.keys(binaries)) {
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
}
// Calculate SHA values
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
.text()
.then((x) => x.trim())
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
// arch
const binaryPkgbuild = [
@@ -57,7 +76,8 @@ if (!Script.preview) {
"# Maintainer: adam",
"",
"pkgname='opencode-bin'",
`pkgver=${Script.version.split("-")[0]}`,
`pkgver=${pkgver}`,
`_subver=${_subver}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
@@ -68,10 +88,10 @@ if (!Script.preview) {
"conflicts=('opencode')",
"depends=('fzf' 'ripgrep')",
"",
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.zip")`,
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.zip")`,
`sha256sums_aarch64=('${arm64Sha}')`,
"",
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.zip")`,
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.zip")`,
`sha256sums_x86_64=('${x64Sha}')`,
"",
"package() {",
@@ -86,7 +106,8 @@ if (!Script.preview) {
"# Maintainer: adam",
"",
"pkgname='opencode'",
`pkgver=${Script.version.split("-")[0]}`,
`pkgver=${pkgver}`,
`_subver=${_subver}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
@@ -98,7 +119,7 @@ if (!Script.preview) {
"depends=('fzf' 'ripgrep')",
"makedepends=('git' 'bun-bin' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v${Script.version}.tar.gz")`,
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun
import { z } from "zod/v4"
import { z } from "zod"
import { Config } from "../src/config/config"
const file = process.argv[2]
+177 -60
View File
@@ -1,16 +1,19 @@
import type {
Agent as ACPAgent,
AgentSideConnection,
AuthenticateRequest,
CancelNotification,
InitializeRequest,
LoadSessionRequest,
NewSessionRequest,
PermissionOption,
PromptRequest,
SetSessionModelRequest,
SetSessionModeRequest,
SetSessionModeResponse,
import {
type Agent as ACPAgent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PermissionOption,
type PlanEntry,
type PromptRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
type SetSessionModeResponse,
type ToolCallContent,
type ToolKind,
} from "@agentclientprotocol/sdk"
import { Log } from "../util/log"
import { ACPSessionManager } from "./session"
@@ -25,24 +28,17 @@ import { Storage } from "@/storage/storage"
import { Command } from "@/command"
import { Agent as Agents } from "@/agent/agent"
import { Permission } from "@/permission"
import { Session } from "@/session"
import { Identifier } from "@/id/id"
import { SessionCompaction } from "@/session/compaction"
import type { Config } from "@/config/config"
import { MCP } from "@/mcp"
import { Todo } from "@/session/todo"
import { z } from "zod"
export namespace ACP {
const log = Log.create({ service: "acp-agent" })
// TODO: mcp servers?
type ToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other"
export class Agent implements ACPAgent {
private sessionManager = new ACPSessionManager()
private connection: AgentSideConnection
@@ -157,6 +153,62 @@ export namespace ACP {
})
break
case "completed":
const kind = toToolKind(part.tool)
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
]
if (kind === "edit") {
const input = part.state.input
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
const newText =
typeof input["newString"] === "string"
? input["newString"]
: typeof input["content"] === "string"
? input["content"]
: ""
content.push({
type: "diff",
path: filePath,
oldText,
newText,
})
}
if (part.tool === "todowrite") {
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
if (parsedTodos.success) {
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
update: {
sessionUpdate: "plan",
entries: parsedTodos.data.map((todo) => {
const status: PlanEntry["status"] =
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
return {
priority: "medium",
status,
content: todo.content,
}
}),
},
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
} else {
log.error("failed to parse todo output", { error: parsedTodos.error })
}
}
await this.connection
.sessionUpdate({
sessionId: acpSession.id,
@@ -164,15 +216,8 @@ export namespace ACP {
sessionUpdate: "tool_call_update",
toolCallId: part.callID,
status: "completed",
content: [
{
type: "content",
content: {
type: "text",
text: part.state.output,
},
},
],
kind,
content,
title: part.state.title,
rawOutput: {
output: part.state.output,
@@ -258,11 +303,14 @@ export namespace ACP {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
// TODO: map acp mcp
// mcpCapabilities: {
// http: true,
// sse: true,
// },
mcpCapabilities: {
http: true,
sse: true,
},
promptCapabilities: {
embeddedContext: true,
image: true,
},
},
authMethods: [
{
@@ -287,6 +335,7 @@ export namespace ACP {
const model = await defaultModel(this.config)
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
log.info("creating_session", { mcpServers: params.mcpServers.length })
const load = await this.loadSession({
cwd: params.cwd,
mcpServers: params.mcpServers,
@@ -325,6 +374,17 @@ export namespace ACP {
name: command.name,
description: command.description ?? "",
}))
const names = new Set(availableCommands.map((c) => c.name))
if (!names.has("init"))
availableCommands.push({
name: "init",
description: "create/update a AGENTS.md",
})
if (!names.has("compact"))
availableCommands.push({
name: "compact",
description: "compact the session",
})
setTimeout(() => {
this.connection.sessionUpdate({
@@ -346,6 +406,35 @@ export namespace ACP {
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
const mcpServers: Record<string, Config.Mcp> = {}
for (const server of params.mcpServers) {
if ("type" in server) {
mcpServers[server.name] = {
url: server.url,
headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
acc[name] = value
return acc
}, {}),
type: "remote",
}
} else {
mcpServers[server.name] = {
type: "local",
command: [server.command, ...server.args],
environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
acc[name] = value
return acc
}, {}),
}
}
}
await Promise.all(
Object.entries(mcpServers).map(async ([key, mcp]) => {
await MCP.add(key, mcp)
}),
)
return {
sessionId,
models: {
@@ -452,25 +541,25 @@ export namespace ACP {
log.info("parts", { parts })
const cmd = await (async () => {
const text = parts.filter((part) => part.type === "text").join("")
const match = text.match(/^\/(\w+)\s*(.*)$/)
if (!match) return
const cmd = (() => {
const text = parts
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("")
.trim()
const [c, args] = match.slice(1)
const command = await Command.get(c)
if (!command) return
return { command, args }
if (!text.startsWith("/")) return
const [name, ...rest] = text.slice(1).split(/\s+/)
return { name, args: rest.join(" ").trim() }
})()
if (cmd) {
await SessionPrompt.command({
sessionID,
command: cmd.command.name,
arguments: cmd.args,
agent,
})
} else {
const done = {
stopReason: "end_turn" as const,
_meta: {},
}
if (!cmd) {
await SessionPrompt.prompt({
sessionID,
model: {
@@ -480,12 +569,40 @@ export namespace ACP {
parts,
agent,
})
return done
}
return {
stopReason: "end_turn" as const,
_meta: {},
const command = await Command.get(cmd.name)
if (command) {
await SessionPrompt.command({
sessionID,
command: command.name,
arguments: cmd.args,
model: model.providerID + "/" + model.modelID,
agent,
})
return done
}
switch (cmd.name) {
case "init":
await Session.initialize({
sessionID,
messageID: Identifier.ascending("message"),
providerID: model.providerID,
modelID: model.modelID,
})
break
case "compact":
await SessionCompaction.run({
sessionID,
providerID: model.providerID,
modelID: model.modelID,
})
break
}
return done
}
async cancel(params: CancelNotification) {
-1
View File
@@ -1,5 +1,4 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import { Identifier } from "../id/id"
import { Session } from "../session"
import { Provider } from "../provider/provider"
import type { ACPSessionState } from "./types"
+1 -1
View File
@@ -1,5 +1,5 @@
import { Config } from "../config/config"
import z from "zod/v4"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
@@ -1,147 +0,0 @@
import z from "zod/v4"
import { Auth } from "./index"
import { NamedError } from "../util/error"
export namespace AuthGithubCopilot {
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
const DEVICE_CODE_URL = "https://github.com/login/device/code"
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
interface DeviceCodeResponse {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
interface AccessTokenResponse {
access_token?: string
error?: string
error_description?: string
}
interface CopilotTokenResponse {
token: string
expires_at: number
refresh_in: number
endpoints: {
api: string
}
}
export async function authorize() {
const deviceResponse = await fetch(DEVICE_CODE_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
scope: "read:user",
}),
})
const deviceData: DeviceCodeResponse = await deviceResponse.json()
return {
device: deviceData.device_code,
user: deviceData.user_code,
verification: deviceData.verification_uri,
interval: deviceData.interval || 5,
expiry: deviceData.expires_in,
}
}
export async function poll(device_code: string) {
const response = await fetch(ACCESS_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "GitHubCopilotChat/0.26.7",
},
body: JSON.stringify({
client_id: CLIENT_ID,
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
})
if (!response.ok) return "failed"
const data: AccessTokenResponse = await response.json()
if (data.access_token) {
// Store the GitHub OAuth token
await Auth.set("github-copilot", {
type: "oauth",
refresh: data.access_token,
access: "",
expires: 0,
})
return "complete"
}
if (data.error === "authorization_pending") return "pending"
if (data.error) return "failed"
return "pending"
}
export async function access() {
const info = await Auth.get("github-copilot")
if (!info || info.type !== "oauth") return
if (info.access && info.expires > Date.now()) return info.access
// Get new Copilot API token
const response = await fetch(COPILOT_API_KEY_URL, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${info.refresh}`,
"User-Agent": "GitHubCopilotChat/0.26.7",
"Editor-Version": "vscode/1.99.3",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
})
if (!response.ok) return
const tokenData: CopilotTokenResponse = await response.json()
// Store the Copilot API token
await Auth.set("github-copilot", {
type: "oauth",
refresh: info.refresh,
access: tokenData.token,
expires: tokenData.expires_at * 1000,
})
return tokenData.token
}
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
export const TokenExchangeError = NamedError.create(
"TokenExchangeError",
z.object({
message: z.string(),
}),
)
export const AuthenticationError = NamedError.create(
"AuthenticationError",
z.object({
message: z.string(),
}),
)
export const CopilotTokenError = NamedError.create(
"CopilotTokenError",
z.object({
message: z.string(),
}),
)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import z from "zod/v4"
import z from "zod"
export namespace Auth {
export const Oauth = z
+1 -1
View File
@@ -1,4 +1,4 @@
import z from "zod/v4"
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
+2 -2
View File
@@ -1,5 +1,5 @@
import z from "zod/v4"
import type { ZodType } from "zod/v4"
import z from "zod"
import type { ZodType } from "zod"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
+6 -3
View File
@@ -6,9 +6,12 @@ export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
directory,
init: InstanceBootstrap,
fn: async () => {
const result = await cb()
await Instance.dispose()
return result
try {
const result = await cb()
return result
} finally {
await Instance.dispose()
}
},
})
}
+6 -2
View File
@@ -29,7 +29,7 @@ export const AcpCommand = cmd({
const input = new WritableStream<Uint8Array>({
write(chunk) {
return new Promise<void>((resolve, reject) => {
process.stdout.write(Buffer.from(chunk), (err) => {
process.stdout.write(chunk, (err) => {
if (err) {
reject(err)
} else {
@@ -56,7 +56,11 @@ export const AcpCommand = cmd({
}, stream)
log.info("setup connection")
process.stdin.resume()
await new Promise((resolve, reject) => {
process.stdin.on("end", resolve)
process.stdin.on("error", reject)
})
})
process.stdin.resume()
},
})
@@ -1,3 +1,4 @@
import { EOL } from "os"
import { Config } from "../../../config/config"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -8,7 +9,7 @@ export const ConfigCommand = cmd({
async handler() {
await bootstrap(process.cwd(), async () => {
const config = await Config.get()
console.log(JSON.stringify(config, null, 2))
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
})
},
})
+4 -4
View File
@@ -14,7 +14,7 @@ const FileSearchCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const results = await File.search({ query: args.query })
console.log(results.join(EOL))
process.stdout.write(results.join(EOL) + EOL)
})
},
})
@@ -30,7 +30,7 @@ const FileReadCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const content = await File.read(args.path)
console.log(content)
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
})
},
})
@@ -41,7 +41,7 @@ const FileStatusCommand = cmd({
async handler() {
await bootstrap(process.cwd(), async () => {
const status = await File.status()
console.log(JSON.stringify(status, null, 2))
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
})
},
})
@@ -57,7 +57,7 @@ const FileListCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
const files = await File.list(args.path)
console.log(JSON.stringify(files, null, 2))
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
})
},
})
+4 -3
View File
@@ -2,6 +2,7 @@ import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
export const LSPCommand = cmd({
command: "lsp",
@@ -16,7 +17,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},
})
@@ -28,7 +29,7 @@ export const SymbolsCommand = cmd({
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
})
@@ -40,7 +41,7 @@ export const DocumentSymbolsCommand = cmd({
await bootstrap(process.cwd(), async () => {
using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri)
console.log(JSON.stringify(results, null, 2))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
})
},
})
@@ -18,7 +18,7 @@ const TreeCommand = cmd({
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
console.log(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }))
process.stdout.write(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }) + EOL)
})
},
})
@@ -49,7 +49,7 @@ const FilesCommand = cmd({
files.push(file)
if (args.limit && files.length >= args.limit) break
}
console.log(files.join(EOL))
process.stdout.write(files.join(EOL) + EOL)
})
},
})
@@ -78,6 +78,6 @@ const SearchCommand = cmd({
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
},
})

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