diff options
| author | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
| commit | 99158e736bd983ee5c62bfc43032da1bafc45d71 (patch) | |
| tree | 59b1afc0fff72a8c47ca76c90a767aafae7a5f45 | |
| parent | 4c02d515a1e15a99fc009587e821087e042bd45b (diff) | |
| parent | f9d5e1879056dd9507bb1a1645da5b5ede87fcca (diff) | |
| download | opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.tar.gz opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
103 files changed, 1810 insertions, 598 deletions
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1e5f56ba6..add68dc62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -184,4 +184,4 @@ jobs: updaterJsonPreferNsis: true releaseId: ${{ needs.publish.outputs.releaseId }} tagName: ${{ needs.publish.outputs.tagName }} - assetName: opencode-desktop-[platform]-[arch][ext] + releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] @@ -20,7 +20,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -48,7 +48,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -75,7 +75,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -99,7 +99,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -123,7 +123,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -168,7 +168,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -197,7 +197,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "22.0.0", @@ -213,7 +213,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.0.149", + "version": "1.0.150", "bin": { "opencode": "./bin/opencode", }, @@ -305,7 +305,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -325,7 +325,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.0.149", + "version": "1.0.150", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -336,7 +336,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -349,7 +349,7 @@ }, "packages/tauri": { "name": "@opencode-ai/tauri", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@opencode-ai/desktop": "workspace:*", "@tauri-apps/api": "^2", @@ -357,7 +357,9 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-shell": "~2", + "@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-updater": "~2", + "@tauri-apps/plugin-window-state": "~2", "solid-js": "catalog:", }, "devDependencies": { @@ -371,7 +373,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -403,7 +405,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "zod": "catalog:", }, @@ -414,7 +416,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -1662,8 +1664,12 @@ "@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ=="], + "@tauri-apps/plugin-store": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA=="], + "@tauri-apps/plugin-updater": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], + "@tauri-apps/plugin-window-state": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="], + "@thisbeyond/solid-dnd": ["@thisbeyond/[email protected]", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], "@tokenizer/token": ["@tokenizer/[email protected]", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], diff --git a/flake.lock b/flake.lock index 4822d9da5..58344d82c 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1765270179, - "narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=", + "lastModified": 1765425892, + "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9", + "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093", "type": "github" }, "original": { diff --git a/github/action.yml b/github/action.yml index d22d19990..f52f14d80 100644 --- a/github/action.yml +++ b/github/action.yml @@ -20,10 +20,29 @@ inputs: runs: using: "composite" steps: + - name: Get opencode version + id: version + shell: bash + run: | + VERSION=$(curl -sf https://api.github.com/repos/sst/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) + echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT + + - name: Cache opencode + id: cache + uses: actions/cache@v4 + with: + path: ~/.opencode/bin + key: opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.version.outputs.version }} + - name: Install opencode + if: steps.cache.outputs.cache-hit != 'true' shell: bash run: curl -fsSL https://opencode.ai/install | bash + - name: Add opencode to PATH + shell: bash + run: echo "$HOME/.opencode/bin" >> $GITHUB_PATH + - name: Run opencode shell: bash id: run_opencode diff --git a/infra/console.ts b/infra/console.ts index 0a98ab072..8f54823f8 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -102,6 +102,7 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS2"), new sst.Secret("ZEN_MODELS3"), new sst.Secret("ZEN_MODELS4"), + new sst.Secret("ZEN_MODELS5"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { diff --git a/nix/hashes.json b/nix/hashes.json index 53a696f85..18d4621ed 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-3GaqUwomnIUW8MqUi1jDVPHQ/C5Z+D9wMR//tAGxvSQ=" + "nodeModules": "sha256-b6AEbARiEcI/Pu1g0LbRfH1Oo5rClncW44Ug0d4oP0w=" } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 02c06a791..9831346f2 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.0.149", + "version": "1.0.150", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index a2812fee9..39e833973 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -169,7 +169,6 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { </Match> </Switch> </li> - </ul> </nav> <nav data-component="nav-mobile"> @@ -181,7 +180,7 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { class="nav-toggle" onClick={() => setStore("mobileMenuOpen", !store.mobileMenuOpen)} > - <span class="sr-only">Open menu</span> + <span class="sr-only">Open menu</span> <Switch> <Match when={store.mobileMenuOpen}> <svg diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index 311b641c6..2616b7ea1 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -1,6 +1,6 @@ import "./index.css" import { Title, Meta, Link } from "@solidjs/meta" -import { A } from "@solidjs/router" +import { A, createAsync, query } from "@solidjs/router" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { IconCopy, IconCheck } from "~/component/icon" @@ -9,6 +9,13 @@ import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png" import { Legal } from "~/component/legal" import { config } from "~/config" +const getLatestRelease = query(async () => { + const response = await fetch("https://api.github.com/repos/sst/opencode/releases/latest") + if (!response.ok) return null + const data = await response.json() + return data.tag_name as string +}, "latest-release") + function CopyStatus() { return ( <span data-component="copy-status"> @@ -19,6 +26,14 @@ function CopyStatus() { } export default function Download() { + const release = createAsync(() => getLatestRelease(), { + deferStream: true, + }) + const download = () => { + const version = release() + if (!version) return null + return `https://github.com/sst/opencode/releases/download/${version}` + } const handleCopyClick = (command: string) => (event: Event) => { const button = event.currentTarget as HTMLButtonElement navigator.clipboard.writeText(command) @@ -43,17 +58,6 @@ export default function Download() { <div data-component="hero-text"> <h1>Download OpenCode</h1> <p>Available in Beta for macOS, Windows, and Linux</p> - <button data-component="download-button"> - <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> - Download for macOS - </button> </div> </section> @@ -117,7 +121,7 @@ export default function Download() { macOS (<span data-slot="hide-narrow">Apple </span>Silicon) </span> </div> - <a href="#" data-component="action-button"> + <a href={download() + "/opencode-desktop-darwin-aarch64.dmg"} data-component="action-button"> Download </a> </div> @@ -133,7 +137,7 @@ export default function Download() { </span> <span>macOS (Intel)</span> </div> - <a href="#" data-component="action-button"> + <a href={download() + "/opencode-desktop-darwin-x64.dmg"} data-component="action-button"> Download </a> </div> @@ -156,7 +160,7 @@ export default function Download() { </span> <span>Windows (x64)</span> </div> - <a href="#" data-component="action-button"> + <a href={download() + "/opencode-desktop-windows-x64.exe"} data-component="action-button"> Download </a> </div> @@ -164,22 +168,15 @@ export default function Download() { <div data-component="download-info"> <span data-slot="icon"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> - <g clip-path="url(#clip0_2614_159729)"> - <path - d="M2 2H11.481V11.4769H2V2ZM12.519 2H22V11.4769H12.519V2ZM2 12.519H11.481V22H2V12.519ZM12.519 12.519H22V22H12.519" - fill="currentColor" - /> - </g> - <defs> - <clipPath id="clip0_2614_159729"> - <rect width="20" height="20" fill="white" transform="translate(2 2)" /> - </clipPath> - </defs> + <path + d="M4.34591 22.7088C5.61167 22.86 7.03384 23.6799 8.22401 23.8247C9.42058 23.9758 9.79086 23.0098 9.79086 23.0098C9.79086 23.0098 11.1374 22.7088 12.553 22.6741C13.97 22.6344 15.3113 22.9688 15.3113 22.9688C15.3113 22.9688 15.5714 23.5646 16.057 23.8247C16.5426 24.0898 17.588 24.1257 18.258 23.4198C18.9293 22.7088 20.7204 21.8132 21.7261 21.2533C22.7382 20.6922 22.5525 19.8364 21.917 19.5763C21.2816 19.3163 20.7614 18.9063 20.8011 18.1196C20.8357 17.3394 20.24 16.8193 20.24 16.8193C20.24 16.8193 20.7614 15.1025 20.2759 13.6805C19.7903 12.2648 18.1889 9.98819 16.9577 8.27657C15.7266 6.55985 16.7719 4.5779 15.651 2.04503C14.5299 -0.491656 11.623 -0.341713 10.0562 0.739505C8.4893 1.8208 8.96968 4.50225 9.04526 5.77447C9.12084 7.04022 9.07985 7.94598 8.93509 8.27146C8.79033 8.60198 7.77951 9.80243 7.1082 10.8081C6.43818 11.819 5.95254 13.906 5.46187 14.7669C4.98142 15.6228 5.31711 16.403 5.31711 16.403C5.31711 16.403 4.98149 16.5182 4.71628 17.0795C4.45616 17.6342 3.93601 17.8993 2.99948 18.0801C2.06934 18.2709 2.06934 18.8705 2.29357 19.5419C2.51902 20.2119 2.29357 20.5873 2.03346 21.4431C1.77342 22.2988 3.07506 22.5588 4.34591 22.7088ZM17.5034 18.805C18.1683 19.0958 19.124 18.691 19.4149 18.4001C19.7045 18.1106 19.9094 17.6801 19.9094 17.6801C19.9094 17.6801 20.2002 17.8249 20.1707 18.2848C20.14 18.7512 20.3706 19.4161 20.8062 19.6467C21.2418 19.876 21.9067 20.1963 21.5621 20.5166C21.211 20.8369 19.2688 21.6183 18.6885 22.2282C18.1132 22.8341 17.3573 23.33 16.8974 23.1839C16.4324 23.0391 16.0262 22.4037 16.2261 21.4736C16.4324 20.5473 16.6066 19.5313 16.5771 18.951C16.5464 18.3707 16.4324 17.5892 16.5771 17.4738C16.7219 17.3598 16.9525 17.4148 16.9525 17.4148C16.9525 17.4148 16.8371 18.5156 17.5034 18.805ZM13.1885 3.12632C13.829 3.12632 14.3454 3.76175 14.3454 4.54324C14.3454 5.09798 14.0853 5.57844 13.7048 5.80906C13.6087 5.76937 13.5087 5.72449 13.3986 5.67832C13.6292 5.56434 13.7893 5.27352 13.7893 4.93783C13.7893 4.49844 13.519 4.13714 13.1794 4.13714C12.8489 4.13714 12.5734 4.49836 12.5734 4.93783C12.5734 5.09806 12.6132 5.25813 12.6785 5.38369C12.4786 5.30293 12.298 5.23383 12.1532 5.17874C12.0776 4.98781 12.0328 4.77257 12.0328 4.54331C12.0328 3.76183 12.5478 3.12632 13.1885 3.12632ZM11.6024 5.56823C11.9176 5.62331 12.7835 5.9987 13.1039 6.11398C13.4242 6.22415 13.7791 6.4291 13.7445 6.63413C13.7048 6.84548 13.5395 6.84548 13.1039 7.1107C12.6735 7.37082 11.7331 7.95116 11.432 7.99085C11.1322 8.03055 10.9618 7.86141 10.6415 7.65516C10.3211 7.44503 9.72039 6.95436 9.87147 6.69432C9.87147 6.69432 10.3416 6.33432 10.5467 6.14986C10.7517 5.95893 11.2821 5.50925 11.6024 5.56823ZM10.2213 3.35185C10.726 3.35185 11.1373 3.95268 11.1373 4.69318C11.1373 4.82773 11.1219 4.95322 11.0976 5.07878C10.972 5.11847 10.8466 5.18385 10.726 5.28891C10.6671 5.33889 10.612 5.38369 10.5621 5.43367C10.6415 5.28381 10.6722 5.06857 10.6363 4.84305C10.5672 4.44335 10.2968 4.14743 10.0316 4.18712C9.76511 4.232 9.60625 4.5984 9.67033 5.00327C9.74081 5.41325 10.0059 5.7091 10.2763 5.6643C10.2917 5.6592 10.3058 5.65409 10.3211 5.64891C10.1918 5.77447 10.0713 5.88464 9.94576 5.97432C9.58065 5.80388 9.31033 5.29402 9.31033 4.69318C9.31041 3.94758 9.71521 3.35185 10.2213 3.35185ZM7.40915 13.045C7.9293 12.2251 8.26492 10.4328 8.78507 9.83702C9.31041 9.24259 9.71521 7.97554 9.53075 7.41569C9.53075 7.41569 10.6517 8.75702 11.432 8.53668C12.2135 8.31116 13.97 7.00571 14.23 7.22994C14.4901 7.45539 16.727 12.375 16.9525 13.9419C17.178 15.5074 16.8026 16.7041 16.8026 16.7041C16.8026 16.7041 15.9468 16.4785 15.8366 16.9987C15.7264 17.524 15.7264 19.4265 15.7264 19.4265C15.7264 19.4265 14.5695 21.0279 12.7784 21.2931C10.9874 21.5532 10.0905 21.3636 10.0905 21.3636L9.08481 20.2118C9.08481 20.2118 9.86637 20.0965 9.75612 19.3112C9.64595 18.531 7.36801 17.4496 6.95803 16.4785C6.5482 15.5073 6.8826 13.8662 7.40915 13.045ZM2.9802 18.9204C3.06988 18.5361 4.23056 18.5361 4.67643 18.2657C5.12229 17.9954 5.21189 17.219 5.57197 17.0141C5.92679 16.804 6.58279 17.5496 6.85311 17.9697C7.11833 18.3797 8.13433 20.1721 8.54942 20.6179C8.96961 21.0676 9.35528 21.6633 9.23483 22.1988C9.12084 22.7343 8.48923 23.1251 8.48923 23.1251C7.92427 23.2993 6.34843 22.619 5.63231 22.3192C4.9162 22.0182 3.09433 21.9284 2.8599 21.6633C2.61906 21.393 2.97517 20.7972 3.06995 20.2322C3.15445 19.6609 2.8893 19.306 2.9802 18.9204Z" + fill="currentColor" + /> </svg> </span> - <span>Windows (Arm)</span> + <span>Linux (.deb)</span> </div> - <a href="#" data-component="action-button"> + <a href={download() + "/opencode-desktop-linux-amd64.deb"} data-component="action-button"> Download </a> </div> @@ -193,9 +190,9 @@ export default function Download() { /> </svg> </span> - <span>Linux</span> + <span>Linux (.rpm)</span> </div> - <a href="#" data-component="action-button"> + <a href={download() + "/opencode-desktop-linux-x86_64.rpm"} data-component="action-button"> Download </a> </div> diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index fb3d0f9dc..9948551e4 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -52,8 +52,6 @@ export default function Home() { <div data-component="content"> <section data-component="hero"> - - <div data-slot="hero-copy"> {/*<a data-slot="releases"*/} {/* href={release()?.url ?? `${config.github.repoUrl}/releases`}*/} @@ -654,13 +652,21 @@ export default function Home() { </li> <li> <Faq question="Do I need extra AI subscriptions to use OpenCode?"> - Not necessarily, but probably. You’ll need an AI subscription if you want to connect OpenCode to a - paid provider, although you can work with{" "} + Not necessarily, OpenCode comes with a set of free models that you can use without creating an + account. Aside from these, you can use any of the popular coding models by creating a{" "} + <A href="/zen">Zen</A> account. While we encourage users to use Zen, OpenCode also works with all + popular providers such as OpenAI, Anthropic, xAI etc. You can even connect your{" "} <a href="/docs/providers/#lm-studio" target="_blank"> local models - </a>{" "} - for free. While we encourage users to use <A href="/zen">Zen</A>, OpenCode works with all popular - providers such as OpenAI, Anthropic, xAI etc. + </a> + . + </Faq> + </li> + <li> + <Faq question="Can I use my existing AI subscriptions with OpenCode?"> + Yes, OpenCode supports subscription plans from all major providers. You can use your Claude Pro/Max, + ChatGPT Plus/Pro, or GitHub Copilot subscriptions. <a href="/docs/providers/#directory">Learn more</a> + . </Faq> </li> <li> @@ -670,13 +676,14 @@ export default function Home() { </li> <li> <Faq question="How much does OpenCode cost?"> - OpenCode is 100% free to use. Any additional costs will come from your subscription to a model - provider. While OpenCode works with any model provider, we recommend using <A href="/zen">Zen</A>. + OpenCode is 100% free to use. It also comes with a set of free models. There might be additional costs + if you connect any other provider. </Faq> </li> <li> <Faq question="What about data and privacy?"> - Your data and information is only stored when you create sharable links in OpenCode. Learn more about{" "} + Your data and information is only stored when you use our free models or create sharable links. Learn + more about <a href="/docs/zen/#privacy">our models</a> and{" "} <a href="/docs/share/#privacy">share pages</a>. </Faq> </li> diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index 30815336d..e760ccea2 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -43,9 +43,12 @@ const getModelsInfo = query(async (workspaceID: string) => { const pA = getPriority(idA) const pB = getPriority(idB) if (pA !== pB) return pA - pB - return modelA.name.localeCompare(modelB.name) + + const modelAName = Array.isArray(modelA) ? modelA[0].name : modelA.name + const modelBName = Array.isArray(modelB) ? modelB[0].name : modelB.name + return modelAName.localeCompare(modelBName) }) - .map(([id, model]) => ({ id, name: model.name })), + .map(([id, model]) => ({ id, name: Array.isArray(model) ? model[0].name : model.name })), disabled: await Model.listDisabled(), } }, workspaceID) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 7d7767b8d..5e9c877cc 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -57,15 +57,17 @@ export async function handler( const sessionId = input.request.headers.get("x-opencode-session") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? "" + const ocClient = input.request.headers.get("x-opencode-client") ?? "" logger.metric({ is_tream: isStream, session: sessionId, request: requestId, + client: ocClient, }) const zenData = ZenData.list() const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId, projectId) - const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip) + const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) const isTrial = await trialLimiter?.isTrial() const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) await rateLimiter?.check() @@ -286,11 +288,14 @@ export async function handler( } function validateModel(zenData: ZenData, reqModel: string) { - if (!(reqModel in zenData.models)) { - throw new ModelError(`Model ${reqModel} not supported`) - } + if (!(reqModel in zenData.models)) throw new ModelError(`Model ${reqModel} not supported`) + const modelId = reqModel as keyof typeof zenData.models - const modelData = zenData.models[modelId] + const modelData = Array.isArray(zenData.models[modelId]) + ? zenData.models[modelId].find((model) => opts.format === model.formatFilter) + : zenData.models[modelId] + + if (!modelData) throw new ModelError(`Model ${reqModel} not supported for format ${opts.format}`) logger.metric({ model: modelId }) diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts index 15561c9f6..531e5cf0c 100644 --- a/packages/console/app/src/routes/zen/util/trialLimiter.ts +++ b/packages/console/app/src/routes/zen/util/trialLimiter.ts @@ -1,12 +1,18 @@ import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js" import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { UsageInfo } from "./provider/provider" +import { ZenData } from "@opencode-ai/console-core/model.js" -export function createTrialLimiter(limit: number | undefined, ip: string) { - if (!limit) return +export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string, client: string) { + if (!trial) return if (!ip) return - let trial: boolean + const limit = + trial.limits.find((limit) => limit.client === client)?.limit ?? + trial.limits.find((limit) => limit.client === undefined)?.limit + if (!limit) return + + let _isTrial: boolean return { isTrial: async () => { @@ -20,11 +26,11 @@ export function createTrialLimiter(limit: number | undefined, ip: string) { .then((rows) => rows[0]), ) - trial = (data?.usage ?? 0) < limit - return trial + _isTrial = (data?.usage ?? 0) < limit + return _isTrial }, track: async (usageInfo: UsageInfo) => { - if (!trial) return + if (!_isTrial) return const usage = usageInfo.inputTokens + usageInfo.outputTokens + diff --git a/packages/console/core/package.json b/packages/console/core/package.json index a1cb40797..86a59d6bb 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.0.149", + "version": "1.0.150", "private": true, "type": "module", "dependencies": { diff --git a/packages/console/core/script/promote-models.ts b/packages/console/core/script/promote-models.ts index 0ff859df8..bebef5cfb 100755 --- a/packages/console/core/script/promote-models.ts +++ b/packages/console/core/script/promote-models.ts @@ -16,16 +16,19 @@ const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1] const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1] const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1] +const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1] if (!value1) throw new Error("ZEN_MODELS1 not found") if (!value2) throw new Error("ZEN_MODELS2 not found") if (!value3) throw new Error("ZEN_MODELS3 not found") if (!value4) throw new Error("ZEN_MODELS4 not found") +if (!value5) throw new Error("ZEN_MODELS5 not found") // validate value -ZenData.validate(JSON.parse(value1 + value2 + value3 + value4)) +ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5)) // update the secret await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}` await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}` await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}` await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}` +await $`bun sst secret set ZEN_MODELS5 ${value5} --stage ${stage}` diff --git a/packages/console/core/script/pull-models.ts b/packages/console/core/script/pull-models.ts index a89e3951c..afa865625 100755 --- a/packages/console/core/script/pull-models.ts +++ b/packages/console/core/script/pull-models.ts @@ -16,16 +16,19 @@ const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1] const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1] const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1] +const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1] if (!value1) throw new Error("ZEN_MODELS1 not found") if (!value2) throw new Error("ZEN_MODELS2 not found") if (!value3) throw new Error("ZEN_MODELS3 not found") if (!value4) throw new Error("ZEN_MODELS4 not found") +if (!value5) throw new Error("ZEN_MODELS5 not found") // validate value -ZenData.validate(JSON.parse(value1 + value2 + value3 + value4)) +ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5)) // update the secret await $`bun sst secret set ZEN_MODELS1 ${value1}` await $`bun sst secret set ZEN_MODELS2 ${value2}` await $`bun sst secret set ZEN_MODELS3 ${value3}` await $`bun sst secret set ZEN_MODELS4 ${value4}` +await $`bun sst secret set ZEN_MODELS5 ${value5}` diff --git a/packages/console/core/script/update-models.ts b/packages/console/core/script/update-models.ts index a8523a5f2..5d40b4d5a 100755 --- a/packages/console/core/script/update-models.ts +++ b/packages/console/core/script/update-models.ts @@ -14,15 +14,17 @@ const oldValue1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("= const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1] const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1] const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1] +const oldValue5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1] if (!oldValue1) throw new Error("ZEN_MODELS1 not found") if (!oldValue2) throw new Error("ZEN_MODELS2 not found") if (!oldValue3) throw new Error("ZEN_MODELS3 not found") if (!oldValue4) throw new Error("ZEN_MODELS4 not found") +if (!oldValue5) throw new Error("ZEN_MODELS5 not found") // store the prettified json to a temp file const filename = `models-${Date.now()}.json` const tempFile = Bun.file(path.join(os.tmpdir(), filename)) -await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4), null, 2)) +await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5), null, 2)) console.log("tempFile", tempFile.name) // open temp file in vim and read the file on close @@ -31,12 +33,15 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text())) ZenData.validate(JSON.parse(newValue)) // update the secret -const chunk = Math.ceil(newValue.length / 4) +const chunk = Math.ceil(newValue.length / 5) const newValue1 = newValue.slice(0, chunk) const newValue2 = newValue.slice(chunk, chunk * 2) const newValue3 = newValue.slice(chunk * 2, chunk * 3) -const newValue4 = newValue.slice(chunk * 3) +const newValue4 = newValue.slice(chunk * 3, chunk * 4) +const newValue5 = newValue.slice(chunk * 4) + await $`bun sst secret set ZEN_MODELS1 ${newValue1}` await $`bun sst secret set ZEN_MODELS2 ${newValue2}` await $`bun sst secret set ZEN_MODELS3 ${newValue3}` await $`bun sst secret set ZEN_MODELS4 ${newValue4}` +await $`bun sst secret set ZEN_MODELS5 ${newValue5}` diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 47ba3e9d8..55d6c895c 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -9,7 +9,17 @@ import { Resource } from "@opencode-ai/console-resource" export namespace ZenData { const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"]) + const TrialSchema = z.object({ + provider: z.string(), + limits: z.array( + z.object({ + limit: z.number(), + client: z.enum(["cli", "desktop"]).optional(), + }), + ), + }) export type Format = z.infer<typeof FormatSchema> + export type Trial = z.infer<typeof TrialSchema> const ModelCostSchema = z.object({ input: z.number(), @@ -26,12 +36,7 @@ export namespace ZenData { allowAnonymous: z.boolean().optional(), byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.boolean().optional(), - trial: z - .object({ - limit: z.number(), - provider: z.string(), - }) - .optional(), + trial: TrialSchema.optional(), rateLimit: z.number().optional(), fallbackProvider: z.string().optional(), providers: z.array( @@ -53,7 +58,7 @@ export namespace ZenData { }) const ModelsSchema = z.object({ - models: z.record(z.string(), ModelSchema), + models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])), providers: z.record(z.string(), ProviderSchema), }) @@ -63,7 +68,11 @@ export namespace ZenData { export const list = fn(z.void(), () => { const json = JSON.parse( - Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value + Resource.ZEN_MODELS3.value + Resource.ZEN_MODELS4.value, + Resource.ZEN_MODELS1.value + + Resource.ZEN_MODELS2.value + + Resource.ZEN_MODELS3.value + + Resource.ZEN_MODELS4.value + + Resource.ZEN_MODELS5.value, ) return ModelsSchema.parse(json) }) diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 0b09bfd0c..632ea3fbe 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -50,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -94,6 +90,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -114,6 +114,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } } } // cloudflare diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 136f4db03..d32bde30c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.0.149", + "version": "1.0.150", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 0b09bfd0c..632ea3fbe 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -50,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -94,6 +90,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -114,6 +114,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } } } // cloudflare diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 2db9de156..764daf918 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.0.149", + "version": "1.0.150", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 0b09bfd0c..632ea3fbe 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -50,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -94,6 +90,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -114,6 +114,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } } } // cloudflare diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 926861010..1d12a9cb9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/desktop", - "version": "1.0.149", + "version": "1.0.150", "description": "", "type": "module", "exports": { diff --git a/packages/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx new file mode 100644 index 000000000..e13c31330 --- /dev/null +++ b/packages/desktop/src/components/link.tsx @@ -0,0 +1,17 @@ +import { ComponentProps, splitProps } from "solid-js" +import { usePlatform } from "@/context/platform" + +export interface LinkProps extends ComponentProps<"button"> { + href: string +} + +export function Link(props: LinkProps) { + const platform = usePlatform() + const [local, rest] = splitProps(props, ["href", "children"]) + + return ( + <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}> + {local.children} + </button> + ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 41af8644b..70ee0a739 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,17 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createSignal } from "solid-js" +import { + createEffect, + on, + Component, + Show, + For, + onMount, + onCleanup, + Switch, + Match, + createSignal, + createMemo, +} from "solid-js" import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" @@ -21,7 +33,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { iife } from "@opencode-ai/util/iife" -import { Input } from "@opencode-ai/ui/input" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -470,60 +481,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </Button> <Show when={layout.dialog.opened() === "model"}> <Switch> - <Match when={providers().connected().length > 0}> - <SelectDialog - defaultOpen - onOpenChange={(open) => { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - 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 (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - } - actions={ - <Button - class="h-7 -my-1 text-14-medium" - icon="plus-small" - tabIndex={-1} - onClick={() => layout.dialog.open("provider")} + <Match when={providers.paid().length > 0}> + {iife(() => { + const models = createMemo(() => + local.model + .list() + .filter((m) => + layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, + ), + ) + return ( + <SelectDialog + defaultOpen + onOpenChange={(open) => { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + 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 (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + } + actions={ + <Button + class="h-7 -my-1 text-14-medium" + icon="plus-small" + tabIndex={-1} + onClick={() => layout.dialog.open("provider")} + > + Connect provider + </Button> + } > - Connect provider - </Button> - } - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - <span>{i.name}</span> - <Show when={!i.cost || i.cost?.input === 0}> - <Tag>Free</Tag> - </Show> - <Show when={i.latest}> - <Tag>Latest</Tag> - </Show> - </div> - )} - </SelectDialog> + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + <span>{i.name}</span> + <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}> + <Tag>Free</Tag> + </Show> + <Show when={i.latest}> + <Tag>Latest</Tag> + </Show> + </div> + )} + </SelectDialog> + ) + })} </Match> <Match when={true}> {iife(() => { @@ -532,6 +556,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + return ( <Dialog modal @@ -549,12 +581,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Dialog.CloseButton tabIndex={-1} /> </Dialog.Header> <Dialog.Body> - <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} /> <div class="flex flex-col gap-3 px-2.5"> <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div> <List ref={(ref) => (listRef = ref)} - items={local.model.list()} + items={local.model.list} current={local.model.current()} key={(x) => `${x.provider.id}:${x.id}`} onSelect={(x) => { @@ -587,7 +618,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <List class="w-full" key={(x) => x?.id} - items={providers().popular()} + items={providers.popular} activeIcon="plus-small" sortBy={(a, b) => { if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 24ba55a53..9cafdce96 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -6,18 +6,26 @@ import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { Project } from "@opencode-ai/sdk/v2" -const PASTEL_COLORS = [ - "#FCEAFD", // pastel pink - "#FFDFBA", // pastel peach - "#FFFFBA", // pastel yellow - "#BAFFC9", // pastel green - "#EAF6FD", // pastel blue - "#EFEAFD", // pastel lavender - "#FEC8D8", // pastel rose - "#D4F0F0", // pastel cyan - "#FDF0EA", // pastel coral - "#C1E1C1", // pastel mint -] +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const + +export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] + +export function isAvatarColorKey(value: string): value is AvatarColorKey { + return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey) +} + +export function getAvatarColors(key?: string) { + if (key && isAvatarColorKey(key)) { + return { + background: `var(--avatar-background-${key})`, + foreground: `var(--avatar-text-${key})`, + } + } + return { + background: "var(--surface-info-base)", + foreground: "var(--text-base)", + } +} type Dialog = "provider" | "model" | "connect" @@ -45,21 +53,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( name: "default-layout.v7", }, ) - const [ephemeral, setEphemeral] = createStore({ + const [ephemeral, setEphemeral] = createStore<{ connect: { - provider: undefined as undefined | string, - state: undefined as undefined | "pending" | "complete" | "error", - error: undefined as undefined | string, - }, + provider?: string + state?: "pending" | "complete" | "error" + error?: string + } dialog: { - open: undefined as undefined | Dialog, - }, + open?: Dialog + } + }>({ + connect: {}, + dialog: {}, }) - const usedColors = new Set<string>() + const usedColors = new Set<AvatarColorKey>() - function pickAvailableColor() { - const available = PASTEL_COLORS.filter((c) => !usedColors.has(c)) - if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] + function pickAvailableColor(): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c)) + if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -177,22 +188,30 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( dialog: { opened: createMemo(() => ephemeral.dialog?.open), open(dialog: Dialog) { - setEphemeral("dialog", "open", dialog) - if (dialog !== "connect") { - setEphemeral("connect", {}) - } + batch(() => { + // if (dialog !== "connect") { + // setEphemeral("connect", {}) + // } + setEphemeral("dialog", "open", dialog) + }) }, close(dialog: Dialog) { - if (ephemeral.dialog?.open === dialog) { - setEphemeral("dialog", "open", undefined) - setEphemeral("connect", {}) + if (ephemeral.dialog.open === dialog) { + setEphemeral( + produce((state) => { + state.dialog.open = undefined + state.connect = {} + }), + ) } }, connect(provider: string) { - batch(() => { - setEphemeral("dialog", "open", "connect") - setEphemeral("connect", { provider, state: "pending" }) - }) + setEphemeral( + produce((state) => { + state.dialog.open = "connect" + state.connect = { provider, state: "pending" } + }), + ) }, }, connect: { diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index d8dfa732a..39fd1f987 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -41,10 +41,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const providers = useProviders() function isModelValid(model: ModelKey) { - const provider = providers().all.find((x) => x.id === model.providerID) + const provider = providers.all().find((x) => x.id === model.providerID) return ( !!provider?.models[model.modelID] && - providers() + providers .connected() .map((p) => p.id) .includes(model.providerID) @@ -123,16 +123,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const list = createMemo(() => - providers() - .connected() - .flatMap((p) => - Object.values(p.models).map((m) => ({ - ...m, - name: m.name.replace("(latest)", "").trim(), - provider: p, - latest: m.name.includes("(latest)"), - })), - ), + providers.connected().flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + provider: p, + latest: m.name.includes("(latest)"), + })), + ), ) const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) @@ -153,11 +151,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - for (const p of providers().connected()) { - if (p.id in providers().default) { + for (const p of providers.connected()) { + if (p.id in providers.default()) { return { providerID: p.id, - modelID: providers().default[p.id], + modelID: providers.default()[p.id], } } } diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index db2b3af7c..860c1a14f 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -62,10 +62,10 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), + .sort((a, b) => a.id.localeCompare(b.id)), ) const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) + return userMessages()?.at(-1) }) const activeMessage = createMemo(() => { if (!store.messageId) return lastUserMessage() diff --git a/packages/desktop/src/hooks/use-providers.ts b/packages/desktop/src/hooks/use-providers.ts index 04ef855d4..501ff9d0c 100644 --- a/packages/desktop/src/hooks/use-providers.ts +++ b/packages/desktop/src/hooks/use-providers.ts @@ -17,13 +17,15 @@ export function useProviders() { return globalSync.data.provider }) const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id))) - const paid = createMemo(() => connected().filter((p) => Object.values(p.models).find((m) => m.cost?.input))) + const paid = createMemo(() => + connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), + ) const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) - return createMemo(() => ({ - all: providers().all, - default: providers().default, + return { + all: createMemo(() => providers().all), + default: createMemo(() => providers().default), popular, connected, paid, - })) + } } diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 257cfc8a3..b997296fa 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,7 +1,7 @@ -import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" -import { useLayout } from "@/context/layout" +import { useLayout, getAvatarColors } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { Mark } from "@opencode-ai/ui/logo" @@ -17,9 +17,9 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { Select } from "@opencode-ai/ui/select" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session, Project, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { DragDropProvider, DragDropSensors, @@ -36,9 +36,12 @@ import { IconName } from "@opencode-ai/ui/icons/provider" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" import { List, ListRef } from "@opencode-ai/ui/list" -import { Input } from "@opencode-ai/ui/input" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast, Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" +import { Spinner } from "@opencode-ai/ui/spinner" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -177,7 +180,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full" /> </div> @@ -197,7 +200,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full" /> </div> @@ -228,7 +231,7 @@ export default function Layout(props: ParentProps) { <Avatar fallback={name()} src={props.project.icon?.url} - background={props.project.icon?.color ?? "var(--surface-info-base)"} + {...getAvatarColors(props.project.icon?.color)} class="size-full group-hover/session:hidden" /> <Icon @@ -487,7 +490,7 @@ export default function Layout(props: ParentProps) { </div> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> <Switch> - <Match when={!providers().paid().length && layout.sidebar.opened()}> + <Match when={!providers.paid().length && layout.sidebar.opened()}> <div class="rounded-md bg-background-stronger shadow-xs-border-base"> <div class="p-3 flex flex-col gap-2"> <div class="text-12-medium text-text-strong">Getting started</div> @@ -533,17 +536,17 @@ export default function Layout(props: ParentProps) { </Button> </Tooltip> </Show> - <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> - <Button - disabled - class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" - variant="ghost" - size="large" - icon="settings-gear" - > - <Show when={layout.sidebar.opened()}>Settings</Show> - </Button> - </Tooltip> + {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */} + {/* <Button */} + {/* disabled */} + {/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */} + {/* variant="ghost" */} + {/* size="large" */} + {/* icon="settings-gear" */} + {/* > */} + {/* <Show when={layout.sidebar.opened()}>Settings</Show> */} + {/* </Button> */} + {/* </Tooltip> */} <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}> <Button as={"a"} @@ -567,7 +570,7 @@ export default function Layout(props: ParentProps) { placeholder="Search providers" activeIcon="plus-small" key={(x) => x?.id} - items={providers().all} + items={providers.all} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} sortBy={(a, b) => { @@ -617,27 +620,102 @@ export default function Layout(props: ParentProps) { </Show> <Show when={layout.dialog.opened() === "connect"}> {iife(() => { + const providerID = createMemo(() => layout.connect.provider()!) + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) + const methods = createMemo( + () => + globalSync.data.provider_auth[providerID()] ?? [ + { + type: "api", + label: "API key", + }, + ], + ) const [store, setStore] = createStore({ method: undefined as undefined | ProviderAuthMethod, + authorization: undefined as undefined | ProviderAuthAuthorization, + state: "pending" as undefined | "pending" | "complete" | "error", + error: undefined as string | undefined, }) - const providerID = layout.connect.provider()! - const provider = globalSync.data.provider.all.find((x) => x.id === providerID)! - const methods = globalSync.data.provider_auth[providerID] ?? [ - { - type: "api", - label: "API key", - }, - ] - if (methods.length === 1) { - setStore("method", methods[0]) + + const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + + async function selectMethod(index: number) { + const method = methods()[index] + setStore( + produce((draft) => { + draft.method = method + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + }), + ) + + if (method.type === "oauth") { + setStore("state", "pending") + const start = Date.now() + await globalSDK.client.provider.oauth + .authorize( + { + providerID: providerID(), + method: index, + }, + { throwOnError: true }, + ) + .then((x) => { + const elapsed = Date.now() - start + const delay = 1000 - elapsed + + if (delay > 0) { + setTimeout(() => { + setStore("state", "complete") + setStore("authorization", x.data!) + }, delay) + return + } + setStore("state", "complete") + setStore("authorization", x.data!) + }) + .catch((e) => { + setStore("state", "error") + setStore("error", String(e)) + }) + } } let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Enter" && e.target instanceof HTMLInputElement) { + return + } if (e.key === "Escape") return listRef?.onKeyDown(e) } + onMount(() => { + if (methods().length === 1) { + selectMethod(0) + } + + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + async function complete() { + await globalSDK.client.global.dispose() + setTimeout(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) + layout.connect.complete() + }, 500) + } + return ( <Dialog modal @@ -657,7 +735,16 @@ export default function Layout(props: ParentProps) { icon="arrow-left" variant="ghost" onClick={() => { - if (store.method && methods.length > 1) { + if (methods().length === 1) { + layout.dialog.open("provider") + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("method", undefined) + return + } + if (store.method) { setStore("method", undefined) return } @@ -670,145 +757,256 @@ export default function Layout(props: ParentProps) { <Dialog.Body> <div class="flex flex-col gap-6 px-2.5 pb-3"> <div class="px-2.5 flex gap-4 items-center"> - <ProviderIcon id={providerID as IconName} class="size-5 shrink-0 icon-strong-base" /> - <div class="text-16-medium text-text-strong">Connect {provider.name}</div> + <ProviderIcon id={providerID() as IconName} class="size-5 shrink-0 icon-strong-base" /> + <div class="text-16-medium text-text-strong"> + <Switch> + <Match + when={providerID() === "anthropic" && store.method?.label?.toLowerCase().includes("max")} + > + Login with Claude Pro/Max + </Match> + <Match when={true}>Connect {provider().name}</Match> + </Switch> + </div> </div> - <Show when={store.method === undefined}> - <div class="px-2.5 text-14-regular text-text-base">Select login method for {provider.name}.</div> - <div class=""> - <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} /> - <List - ref={(ref) => (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={(method) => { - if (!method) return - setStore("method", method) - - if (method.type === "oauth") { - // const result = await sdk.client.provider.oauth.authorize({ - // providerID: provider.id, - // method: index, - // }) - // if (result.data?.method === "code") { - // dialog.replace(() => ( - // <CodeMethod - // providerID={provider.id} - // title={method.label} - // index={index} - // authorization={result.data!} - // /> - // )) - // } - // if (result.data?.method === "auto") { - // dialog.replace(() => ( - // <AutoMethod - // providerID={provider.id} - // title={method.label} - // index={index} - // authorization={result.data!} - // /> - // )) - // } - } - if (method.type === "api") { - // return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) - } - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-2.5"> - {/* TODO: add checkmark thing */} - <span>{i.label}</span> + <div class="px-2.5 pb-10 flex flex-col gap-6"> + <Switch> + <Match when={store.method === undefined}> + <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div> + <div class=""> + <List + ref={(ref) => (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-4"> + <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center"> + <div + class="w-2.5 h-0.5 bg-icon-strong-base hidden" + data-slot="list-item-extra-icon" + /> + </div> + <span>{i.label}</span> + </div> + )} + </List> + </div> + </Match> + <Match when={store.state === "pending"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-4"> + <Spinner /> + <span>Authorization in progress...</span> </div> - )} - </List> - </div> - </Show> - <Show when={store.method?.type === "api"}> - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", "API key is required") - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID, - auth: { - type: "api", - key: apiKey, - }, - }) - await globalSDK.client.global.dispose() - layout.connect.complete() - } + </div> + </Match> + <Match when={store.state === "error"}> + <div class="text-14-regular text-text-base"> + <div class="flex items-center gap-x-4"> + <Icon name="circle-ban-sign" class="text-icon-critical-base" /> + <span>Authorization failed: {store.error}</span> + </div> + </div> + </Match> + <Match when={store.method?.type === "api"}> + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) - return ( - <div class="px-2.5 pb-10 flex flex-col gap-6"> - <Switch> - <Match when={provider.id === "opencode"}> - <div class="flex flex-col gap-4"> - <div class="text-14-regular text-text-base"> - OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. - </div> - <div class="text-14-regular text-text-base"> - With a single API key you’ll get access to models such as Claude, GPT, Gemini, GLM - and more. + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: providerID(), + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( + <div class="flex flex-col gap-6"> + <Switch> + <Match when={provider().id === "opencode"}> + <div class="flex flex-col gap-4"> + <div class="text-14-regular text-text-base"> + OpenCode Zen gives you access to a curated set of reliable optimized models for + coding agents. + </div> + <div class="text-14-regular text-text-base"> + With a single API key you’ll get access to models such as Claude, GPT, Gemini, + GLM and more. + </div> + <div class="text-14-regular text-text-base"> + Visit{" "} + <Link href="https://opencode.ai/zen" tabIndex={-1}> + opencode.ai/zen + </Link>{" "} + to collect your API key. + </div> + </div> + </Match> + <Match when={true}> + <div class="text-14-regular text-text-base"> + Enter your {provider().name} API key to connect your account and use{" "} + {provider().name} models in OpenCode. + </div> + </Match> + </Switch> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={`${provider().name} API key`} + placeholder="API key" + name="apiKey" + value={formStore.value} + onChange={setFormStore.bind(null, "value")} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + Submit + </Button> + </form> + </div> + ) + })} + </Match> + <Match when={store.method?.type === "oauth"}> + <Switch> + <Match when={store.authorization?.method === "code"}> + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", "Authorization code is required") + return + } + + setFormStore("error", undefined) + const { error } = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + code, + }) + if (!error) { + await complete() + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + Visit <Link href={store.authorization!.url}>this link</Link> to collect your + authorization code to connect your account and use {provider().name} models in + OpenCode. + </div> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <TextField + autofocus + type="text" + label={`${store.method?.label} authorization code`} + placeholder="Authorization code" + name="code" + value={formStore.value} + onChange={setFormStore.bind(null, "value")} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + Submit + </Button> + </form> </div> - <div class="text-14-regular text-text-base"> - Visit{" "} - <button - tabIndex={-1} - class="text-text-strong underline" - onClick={() => platform.openLink("https://opencode.ai/zen")} - > - opencode.ai/zen - </button>{" "} - to collect your API key. + ) + })} + </Match> + <Match when={store.authorization?.method === "auto"}> + {iife(() => { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions?.split(":")[1]?.trim() + } + return instructions + }) + + onMount(async () => { + const result = await globalSDK.client.provider.oauth.callback({ + providerID: providerID(), + method: methodIndex(), + }) + if (result.error) { + // TODO: show error + layout.dialog.close("connect") + return + } + await complete() + }) + + return ( + <div class="flex flex-col gap-6"> + <div class="text-14-regular text-text-base"> + Visit <Link href={store.authorization!.url}>this link</Link> and enter the code + below to connect your account and use {provider().name} models in OpenCode. + </div> + <TextField + label="Confirmation code" + class="font-mono" + value={code()} + readOnly + copyable + /> + <div class="text-14-regular text-text-base flex items-center gap-4"> + <Spinner /> + <span>Waiting for authorization...</span> + </div> </div> - </div> - </Match> - <Match when={true}> - <div class="text-14-regular text-text-base"> - Enter your {provider.name} API key to connect your account and use {provider.name}{" "} - models in OpenCode. - </div> - </Match> - </Switch> - <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> - <Input - autofocus - type="text" - label={`${provider.name} API key`} - placeholder="API key" - name="apiKey" - value={formStore.value} - onChange={setFormStore.bind(null, "value")} - validationState={formStore.error ? "invalid" : undefined} - error={formStore.error} - /> - <Button class="w-auto" type="submit" size="large" variant="primary"> - Submit - </Button> - </form> - </div> - ) - })} - </Show> + ) + })} + </Match> + </Switch> + </Match> + </Switch> + </div> </div> </Dialog.Body> </Dialog> @@ -816,6 +1014,7 @@ export default function Layout(props: ParentProps) { })} </Show> </div> + <Toast.Region /> </div> ) } diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 890401723..5dae4ce55 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -415,7 +415,6 @@ export default function Page() { messages={session.messages.user()} current={session.messages.active()} onMessageSelect={session.messages.setActive} - working={session.working()} wide={wide()} /> <SessionTurn diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 81ca2c7de..637d9cc0b 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.0.149", + "version": "1.0.150", "private": true, "type": "module", "scripts": { diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index ec675a38d..7cce15906 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -141,7 +141,10 @@ export default function () { const data = createAsync( async () => { if (!params.shareID) throw new Error("Missing shareID") - return getData(params.shareID) + const now = Date.now() + const data = getData(params.shareID) + console.log("getData", Date.now() - now) + return data }, { deferStream: true, @@ -206,7 +209,7 @@ export default function () { const messages = createMemo(() => data().sessionID ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => b.time.created - a.time.created, + (a, b) => a.time.created - b.time.created, ) : [], ) diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 0b09bfd0c..632ea3fbe 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -50,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -94,6 +90,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -114,6 +114,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } } } // cloudflare diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7e1d150f4..e7cb19deb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.0.149" +version = "1.0.150" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/sst/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.149/opencode-darwin-arm64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.149/opencode-darwin-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.149/opencode-linux-arm64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.149/opencode-linux-x64.tar.gz" +archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/sst/opencode/releases/download/v1.0.149/opencode-windows-x64.zip" +archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index d0c8c8173..591dcfb3c 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.0.149", + "version": "1.0.150", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 0b09bfd0c..632ea3fbe 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -50,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "GITHUB_APP_ID": { "type": "sst.sst.Secret" "value": string @@ -94,6 +90,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -114,6 +114,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } } } // cloudflare diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4a5c0e925..972568983 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.0.149", + "version": "1.0.150", "name": "opencode", "type": "module", "private": true, diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ae4798d96..a1e45e1d2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -28,7 +28,7 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" export namespace ACP { const log = Log.create({ service: "acp-agent" }) @@ -386,7 +386,7 @@ export namespace ACP { log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length }) - const load = await this.loadSession({ + const load = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId, @@ -413,6 +413,242 @@ export namespace ACP { async loadSession(params: LoadSessionRequest) { const directory = params.cwd + const sessionId = params.sessionId + + try { + const model = await defaultModel(this.config, directory) + + // Store ACP session state + const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + + log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) + + const mode = await this.loadSessionMode({ + cwd: directory, + mcpServers: params.mcpServers, + sessionId, + }) + + this.setupEventSubscriptions(state) + + // Replay session history + const messages = await this.sdk.session + .messages( + { + sessionID: sessionId, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((err) => { + log.error("unexpected error when fetching message", { error: err }) + return undefined + }) + + for (const msg of messages ?? []) { + log.debug("replay message", msg) + await this.processMessage(msg) + } + + return mode + } catch (e) { + const error = MessageV2.fromError(e, { + providerID: this.config.defaultModel?.providerID ?? "unknown", + }) + if (LoadAPIKeyError.isInstance(error)) { + throw RequestError.authRequired() + } + throw e + } + } + + private async processMessage(message: SessionMessageResponse) { + log.debug("process message", message) + if (message.info.role !== "assistant" && message.info.role !== "user") return + const sessionId = message.info.sessionID + + for (const part of message.parts) { + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, + }, + }) + .catch((err) => { + log.error("failed to send tool pending to ACP", { error: err }) + }) + break + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((err) => { + log.error("failed to send tool in_progress to ACP", { error: err }) + }) + 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, + 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, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool completed to ACP", { error: err }) + }) + break + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + content: [ + { + type: "content", + content: { + type: "text", + text: part.state.error, + }, + }, + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((err) => { + log.error("failed to send tool error to ACP", { error: err }) + }) + break + } + } else if (part.type === "text") { + if (part.text) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk", + content: { + type: "text", + text: part.text, + }, + }, + }) + .catch((err) => { + log.error("failed to send text to ACP", { error: err }) + }) + } + } else if (part.type === "reasoning") { + if (part.text) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: part.text, + }, + }, + }) + .catch((err) => { + log.error("failed to send reasoning to ACP", { error: err }) + }) + } + } + } + } + + private async loadSessionMode(params: LoadSessionRequest) { + const directory = params.cwd const model = await defaultModel(this.config, directory) const sessionId = params.sessionId diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 6658e4203..70b658347 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -40,6 +40,37 @@ export class ACPSessionManager { return state } + async load( + sessionId: string, + cwd: string, + mcpServers: McpServer[], + model?: ACPSessionState["model"], + ): Promise<ACPSessionState> { + const session = await this.sdk.session + .get( + { + sessionID: sessionId, + directory: cwd, + }, + { throwOnError: true }, + ) + .then((x) => x.data!) + + const resolvedModel = model + + const state: ACPSessionState = { + id: sessionId, + cwd, + mcpServers, + createdAt: new Date(session.time.created), + model: resolvedModel, + } + log.info("loading_session", { state }) + + this.sessions.set(sessionId, state) + return state + } + get(sessionId: string): ACPSessionState { const session = this.sessions.get(sessionId) if (!session) { diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 2f5977195..97cb1a0f3 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -17,6 +17,7 @@ const DiagnosticsCommand = cmd({ async handler(args) { await bootstrap(process.cwd(), async () => { await LSP.touchFile(args.file, true) + await Bun.sleep(1000) process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL) }) }, diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 16544d008..28e841122 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -107,7 +107,9 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi render( () => { return ( - <ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} />}> + <ErrorBoundary + fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />} + > <ArgsProvider {...input.args}> <ExitProvider onExit={onExit}> <KVProvider> @@ -536,7 +538,12 @@ function App() { ) } -function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) { +function ErrorComponent(props: { + error: Error + reset: () => void + onExit: () => Promise<void> + mode?: "dark" | "light" +}) { const term = useTerminalDimensions() useKeyboard((evt) => { if (evt.ctrl && evt.name === "c") { @@ -547,6 +554,15 @@ function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml") + // Choose safe fallback colors per mode since theme context may not be available + const isLight = props.mode === "light" + const colors = { + bg: isLight ? "#ffffff" : "#0a0a0a", + text: isLight ? "#1a1a1a" : "#eeeeee", + muted: isLight ? "#8a8a8a" : "#808080", + primary: isLight ? "#3b7dd8" : "#fab283", + } + if (props.error.message) { issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`) } @@ -567,27 +583,31 @@ function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => } return ( - <box flexDirection="column" gap={1}> + <box flexDirection="column" gap={1} backgroundColor={colors.bg}> <box flexDirection="row" gap={1} alignItems="center"> - <text attributes={TextAttributes.BOLD}>Please report an issue.</text> - <box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}> - <text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text> + <text attributes={TextAttributes.BOLD} fg={colors.text}> + Please report an issue. + </text> + <box onMouseUp={copyIssueURL} backgroundColor={colors.primary} padding={1}> + <text attributes={TextAttributes.BOLD} fg={colors.bg}> + Copy issue URL (exception info pre-filled) + </text> </box> - {copied() && <text>Successfully copied</text>} + {copied() && <text fg={colors.muted}>Successfully copied</text>} </box> <box flexDirection="row" gap={2} alignItems="center"> - <text>A fatal error occurred!</text> - <box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}> - <text>Reset TUI</text> + <text fg={colors.text}>A fatal error occurred!</text> + <box onMouseUp={props.reset} backgroundColor={colors.primary} padding={1}> + <text fg={colors.bg}>Reset TUI</text> </box> - <box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}> - <text>Exit</text> + <box onMouseUp={props.onExit} backgroundColor={colors.primary} padding={1}> + <text fg={colors.bg}>Exit</text> </box> </box> <scrollbox height={Math.floor(term().height * 0.7)}> - <text>{props.error.stack}</text> + <text fg={colors.muted}>{props.error.stack}</text> </scrollbox> - <text>{props.error.message}</text> + <text fg={colors.text}>{props.error.message}</text> </box> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 0af7034db..5cc114f92 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -122,7 +122,9 @@ function AutoMethod(props: AutoMethodProps) { return ( <box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}> <box flexDirection="row" justifyContent="space-between"> - <text attributes={TextAttributes.BOLD}>{props.title}</text> + <text attributes={TextAttributes.BOLD} fg={theme.text}> + {props.title} + </text> <text fg={theme.textMuted}>esc</text> </box> <box gap={1}> @@ -198,7 +200,7 @@ function ApiMethod(props: ApiMethodProps) { <text fg={theme.textMuted}> OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key. </text> - <text> + <text fg={theme.text}> Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key </text> </box> diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 9610ca6d3..f5e0efa49 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -8,6 +8,7 @@ import { Keybind } from "@/util/keybind" import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { DialogSessionRename } from "./dialog-session-rename" +import "opentui-spinner/solid" export function DialogSessionList() { const dialog = useDialog() @@ -22,6 +23,8 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) + const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + const options = createMemo(() => { const today = new Date().toDateString() return sync.data.session @@ -34,12 +37,15 @@ export function DialogSessionList() { category = "Today" } const isDeleting = toDelete() === x.id + const status = sync.data.session_status[x.id] + const isWorking = status?.type === "busy" return { title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title, bg: isDeleting ? theme.error : undefined, value: x.id, category, footer: Locale.time(x.time.updated), + gutter: isWorking ? <spinner frames={spinnerFrames} interval={80} color={theme.primary} /> : undefined, } }) .slice(0, 150) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index f3ce4d4de..4e485b033 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -19,7 +19,7 @@ export function DialogStatus() { </text> <text fg={theme.textMuted}>esc</text> </box> - <Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}> + <Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}> <box> <text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text> <For each={Object.entries(sync.data.mcp)}> diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2e1ec3e42..669ed1897 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -25,6 +25,7 @@ import { Locale } from "@/util/locale" import { createColors, createFrames } from "../../ui/spinner.ts" import { useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderConnect } from "../dialog-provider" +import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" export type PromptProps = { @@ -908,9 +909,14 @@ export function Prompt(props: PromptProps) { if (!r) return if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) return "gemini is way too hot right now" - if (r.message.length > 50) return r.message.slice(0, 50) + "..." + if (r.message.length > 80) return r.message.slice(0, 80) + "..." return r.message }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) const [seconds, setSeconds] = createSignal(0) onMount(() => { const timer = setInterval(() => { @@ -922,12 +928,28 @@ export function Prompt(props: PromptProps) { clearInterval(timer) }) }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + DialogAlert.show(dialog, "Retry Error", r.message) + } + } + + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } + return ( <Show when={retry()}> - <text fg={theme.error}> - {message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""} - attempt #{retry()!.attempt}] - </text> + <box onMouseUp={handleMessageClick}> + <text fg={theme.error}>{retryText()}</text> + </box> </Show> ) })()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx index 8c8576cc0..da868221e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-timeline.tsx @@ -5,8 +5,13 @@ import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" import { DialogMessage } from "./dialog-message" import { useDialog } from "../../ui/dialog" +import type { PromptInfo } from "../../component/prompt/history" -export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) { +export function DialogTimeline(props: { + sessionID: string + onMove: (messageID: string) => void + setPrompt?: (prompt: PromptInfo) => void +}) { const sync = useSync() const dialog = useDialog() @@ -26,10 +31,13 @@ export function DialogTimeline(props: { sessionID: string; onMove: (messageID: s value: message.id, footer: Locale.time(message.time.created), onSelect: (dialog) => { - dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />) + dialog.replace(() => ( + <DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} /> + )) }, }) } + result.reverse() return result }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 22e740afe..1c1e4b65e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -289,6 +289,7 @@ export function Session() { if (child) scroll.scrollBy(child.y - scroll.y - 1) }} sessionID={route.sessionID} + setPrompt={(promptInfo) => prompt.set(promptInfo)} /> )) }, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 508d10838..c1c29a731 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -259,9 +259,11 @@ export function Sidebar(props: { sessionID: string }) { flexDirection="row" gap={1} > - <text flexShrink={0}>⬖</text> + <text flexShrink={0} fg={theme.text}> + ⬖ + </text> <box flexGrow={1} gap={1}> - <text> + <text fg={theme.text}> <b>Getting started</b> </text> <text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text> @@ -269,7 +271,7 @@ export function Sidebar(props: { sessionID: string }) { Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc </text> <box flexDirection="row" gap={1} justifyContent="space-between"> - <text>Connect provider</text> + <text fg={theme.text}>Connect provider</text> <text fg={theme.textMuted}>/connect</text> </box> </box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 96ef982d7..45e946fa7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -22,7 +22,9 @@ export function DialogAlert(props: DialogAlertProps) { return ( <box paddingLeft={2} paddingRight={2} gap={1}> <box flexDirection="row" justifyContent="space-between"> - <text attributes={TextAttributes.BOLD}>{props.title}</text> + <text attributes={TextAttributes.BOLD} fg={theme.text}> + {props.title} + </text> <text fg={theme.textMuted}>esc</text> </box> <box paddingBottom={1}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 9d0e7d2c7..8431a3946 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -34,7 +34,9 @@ export function DialogConfirm(props: DialogConfirmProps) { return ( <box paddingLeft={2} paddingRight={2} gap={1}> <box flexDirection="row" justifyContent="space-between"> - <text attributes={TextAttributes.BOLD}>{props.title}</text> + <text attributes={TextAttributes.BOLD} fg={theme.text}> + {props.title} + </text> <text fg={theme.textMuted}>esc</text> </box> <box paddingBottom={1}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index db9648f2c..056ce41da 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -18,7 +18,9 @@ export function DialogHelp() { return ( <box paddingLeft={2} paddingRight={2} gap={1}> <box flexDirection="row" justifyContent="space-between"> - <text attributes={TextAttributes.BOLD}>Help</text> + <text attributes={TextAttributes.BOLD} fg={theme.text}> + Help + </text> <text fg={theme.textMuted}>esc/enter</text> </box> <box paddingBottom={1}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 4b4c635a5..1b9acb589 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -35,7 +35,9 @@ export function DialogPrompt(props: DialogPromptProps) { return ( <box paddingLeft={2} paddingRight={2} gap={1}> <box flexDirection="row" justifyContent="space-between"> - <text attributes={TextAttributes.BOLD}>{props.title}</text> + <text attributes={TextAttributes.BOLD} fg={theme.text}> + {props.title} + </text> <text fg={theme.textMuted}>esc</text> </box> <box gap={1}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index b6c5b5f8b..3f49a7c32 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -36,6 +36,7 @@ export interface DialogSelectOption<T = any> { category?: string disabled?: boolean bg?: RGBA + gutter?: JSX.Element onSelect?: (ctx: DialogContext, trigger?: "prompt") => void } @@ -239,7 +240,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { moveTo(index) }} backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} - paddingLeft={current() ? 1 : 3} + paddingLeft={current() || option.gutter ? 1 : 3} paddingRight={3} gap={1} > @@ -249,6 +250,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { description={option.description !== category ? option.description : undefined} active={active()} current={current()} + gutter={option.gutter} /> </box> ) @@ -282,6 +284,7 @@ function Option(props: { active?: boolean current?: boolean footer?: JSX.Element | string + gutter?: JSX.Element onMouseOver?: () => void }) { const { theme } = useTheme() @@ -294,6 +297,11 @@ function Option(props: { ● </text> </Show> + <Show when={!props.current && props.gutter}> + <box flexShrink={0} marginRight={0.5}> + {props.gutter} + </box> + </Show> <text flexGrow={1} fg={props.active ? fg : props.current ? theme.primary : theme.text} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be1949c3b..42f6b11e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,6 @@ import { Log } from "../util/log" import path from "path" +import { pathToFileURL } from "url" import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" @@ -297,7 +298,7 @@ export namespace Config { dot: true, cwd: dir, })) { - plugins.push("file://" + item) + plugins.push(pathToFileURL(item).href) } return plugins } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 36cebf6aa..2dcf112ae 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -11,6 +11,7 @@ export namespace Flag { export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] + export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d630c3f93..0359c16fe 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" import { iife } from "@/util/iife" +import { Flag } from "../flag/flag" declare global { const OPENCODE_VERSION: string @@ -162,7 +163,7 @@ export namespace Installation { export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" - export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}` + export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export async function latest(installMethod?: Method) { const detectedMethod = installMethod || (await method()) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 31b8ff711..ce426cf62 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,6 +1,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import path from "path" +import { pathToFileURL, fileURLToPath } from "url" import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" import { Log } from "../util/log" @@ -46,7 +47,7 @@ export namespace LSPClient { const diagnostics = new Map<string, Diagnostic[]>() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const path = new URL(params.uri).pathname + const path = fileURLToPath(params.uri) l.info("textDocument/publishDiagnostics", { path, }) @@ -68,7 +69,7 @@ export namespace LSPClient { connection.onRequest("workspace/workspaceFolders", async () => [ { name: "workspace", - uri: "file://" + input.root, + uri: pathToFileURL(input.root).href, }, ]) connection.listen() @@ -76,12 +77,12 @@ export namespace LSPClient { l.info("sending initialize") await withTimeout( connection.sendRequest("initialize", { - rootUri: "file://" + input.root, + rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [ { name: "workspace", - uri: "file://" + input.root, + uri: pathToFileURL(input.root).href, }, ], initializationOptions: { @@ -154,7 +155,7 @@ export namespace LSPClient { }) await connection.sendNotification("textDocument/didChange", { textDocument: { - uri: `file://` + input.path, + uri: pathToFileURL(input.path).href, version: next, }, contentChanges: [{ text }], @@ -166,7 +167,7 @@ export namespace LSPClient { diagnostics.delete(input.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { - uri: `file://` + input.path, + uri: pathToFileURL(input.path).href, languageId, version: 0, text, diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 096c57a6d..764c91fcc 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -3,6 +3,7 @@ import { Bus } from "@/bus" import { Log } from "../util/log" import { LSPClient } from "./client" import path from "path" +import { pathToFileURL } from "url" import { LSPServer } from "./server" import z from "zod" import { Config } from "../config/config" @@ -270,7 +271,7 @@ export namespace LSP { return run((client) => { return client.connection.sendRequest("textDocument/hover", { textDocument: { - uri: `file://${input.file}`, + uri: pathToFileURL(input.file).href, }, position: { line: input.line, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index b589d6371..c0ee45236 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -226,7 +226,10 @@ export namespace ProviderTransform { } } - if (model.providerID === "baseten") { + if ( + model.providerID === "baseten" || + (model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(model.api.id)) + ) { result["chat_template_args"] = { enable_thinking: true } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ac7077bf7..f1485ec01 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -791,9 +791,11 @@ export namespace Server { "json", z.object({ title: z.string().optional(), - time: z.object({ - archived: z.number().optional(), - }), + time: z + .object({ + archived: z.number().optional(), + }) + .optional(), }), ), async (c) => { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 76c702982..7f1b03c94 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,6 +5,7 @@ import z from "zod" import { Identifier } from "../id/id" import { MessageV2 } from "./message-v2" import { Log } from "../util/log" +import { Flag } from "../flag/flag" import { SessionRevert } from "./revert" import { Session } from "." import { Agent } from "../agent/agent" @@ -29,7 +30,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" -import { mergeDeep, pipe } from "remeda" +import { clone, mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -520,28 +521,33 @@ export namespace SessionPrompt { }) } - const messages = [ + // Deep copy message history so that modifications made by plugins do not + // affect the original messages + const sessionMessages = clone( + msgs.filter((m) => { + if (m.info.role !== "assistant" || m.info.error === undefined) { + return true + } + if ( + MessageV2.AbortedError.isInstance(m.info.error) && + m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) { + return true + } + return false + }), + ) + + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + + const messages: ModelMessage[] = [ ...system.map( (x): ModelMessage => ({ role: "system", content: x, }), ), - ...MessageV2.toModelMessage( - msgs.filter((m) => { - if (m.info.role !== "assistant" || m.info.error === undefined) { - return true - } - if ( - MessageV2.AbortedError.isInstance(m.info.error) && - m.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) { - return true - } - - return false - }), - ), + ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep ? [ { @@ -551,6 +557,7 @@ export namespace SessionPrompt { ] : []), ] + const result = await processor.process({ onError(error) { log.error("stream error", { @@ -584,6 +591,7 @@ export namespace SessionPrompt { "x-opencode-project": Instance.project.id, "x-opencode-session": sessionID, "x-opencode-request": lastUser.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, } : undefined), ...model.headers, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6b0b9d410..0c099fe80 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -82,7 +82,7 @@ export const BashTool = Tool.define("bash", async () => { log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION, + description: DESCRIPTION.replaceAll("${directory}", Instance.directory), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), @@ -188,7 +188,7 @@ export const BashTool = Tool.define("bash", async () => { const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions) if (action === "deny") { throw new Error( - `The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`, + `The user has specifically restricted access to this command: "${command.join(" ")}", you are not allowed to execute it. The user has these settings configured: ${JSON.stringify(permissions)}`, ) } if (action === "ask") { diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 8eadc500b..eff52b1d3 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -1,5 +1,7 @@ Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +All commands run in ${directory} by default. Use the `workdir` parameter if you need to run a command in a different directory. + Before executing the command, please follow these steps: 1. Directory Verification: diff --git a/packages/opencode/src/tool/webfetch.txt b/packages/opencode/src/tool/webfetch.txt index c1217f57b..c5d1e7da2 100644 --- a/packages/opencode/src/tool/webfetch.txt +++ b/packages/opencode/src/tool/webfetch.txt @@ -11,4 +11,3 @@ Usage notes: - The prompt should describe what information you want to extract from the page - This tool is read-only and does not modify any files - Results may be summarized if the content is very large - - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL diff --git a/packages/opencode/src/util/log.ts b/packages/opencode/src/util/log.ts index 209f73032..6941310bb 100644 --- a/packages/opencode/src/util/log.ts +++ b/packages/opencode/src/util/log.ts @@ -50,7 +50,10 @@ export namespace Log { export function file() { return logpath } - let write = (msg: any) => Bun.stderr.write(msg) + let write = (msg: any) => { + process.stderr.write(msg) + return msg.length + } export async function init(options: Options) { if (options.level) level = options.level diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fc474d55c..3256079a5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.0.149", + "version": "1.0.150", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index f00e90482..57ca75d60 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -6,6 +6,7 @@ import type { Provider, Permission, UserMessage, + Message, Part, Auth, Config, @@ -175,6 +176,15 @@ export interface Hooks { metadata: any }, ) => Promise<void> + "experimental.chat.messages.transform"?: ( + input: {}, + output: { + messages: { + info: Message + parts: Part[] + }[] + }, + ) => Promise<void> "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 42de66a06..0ff29129e 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.0.149", + "version": "1.0.150", "type": "module", "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f8890d9fb..9d0bbcc92 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2407,7 +2407,7 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] export type SessionUpdateData = { body?: { title?: string - time: { + time?: { archived?: number } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7c7c216f5..98c8b3586 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1190,8 +1190,7 @@ } } } - }, - "required": ["time"] + } } } } diff --git a/packages/slack/package.json b/packages/slack/package.json index 3298a393c..ab046fc40 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.0.149", + "version": "1.0.150", "type": "module", "scripts": { "dev": "bun run src/index.ts", diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 2aa88e9ec..7e0f670b4 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/tauri", "private": true, - "version": "1.0.149", + "version": "1.0.150", "type": "module", "scripts": { "typecheck": "tsgo -b", @@ -18,7 +18,9 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-shell": "~2", + "@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-updater": "~2", + "@tauri-apps/plugin-window-state": "~2", "solid-js": "catalog:" }, "devDependencies": { diff --git a/packages/tauri/src-tauri/Cargo.lock b/packages/tauri/src-tauri/Cargo.lock index 57d463355..b42329d75 100644 --- a/packages/tauri/src-tauri/Cargo.lock +++ b/packages/tauri/src-tauri/Cargo.lock @@ -2513,7 +2513,9 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-store", "tauri-plugin-updater", + "tauri-plugin-window-state", "tokio", ] @@ -4176,6 +4178,22 @@ dependencies = [ ] [[package]] +name = "tauri-plugin-store" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + +[[package]] name = "tauri-plugin-updater" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4208,6 +4226,21 @@ dependencies = [ ] [[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.10.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", +] + +[[package]] name = "tauri-runtime" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4440,11 +4473,23 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] [[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/packages/tauri/src-tauri/Cargo.toml b/packages/tauri/src-tauri/Cargo.toml index c6b0e409b..f72e5f428 100644 --- a/packages/tauri/src-tauri/Cargo.toml +++ b/packages/tauri/src-tauri/Cargo.toml @@ -23,9 +23,11 @@ tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-updater = "2" +tauri-plugin-process = "2" +tauri-plugin-store = "2" +tauri-plugin-window-state = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = "1.48.0" listeners = "0.3" -tauri-plugin-process = "2" diff --git a/packages/tauri/src-tauri/capabilities/default.json b/packages/tauri/src-tauri/capabilities/default.json index ef5a207b4..320586de4 100644 --- a/packages/tauri/src-tauri/capabilities/default.json +++ b/packages/tauri/src-tauri/capabilities/default.json @@ -11,6 +11,8 @@ "shell:default", "updater:default", "dialog:default", - "process:default" + "process:default", + "store:default", + "window-state:default" ] } diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index d79932574..eb712235e 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, Manager, RunEvent, WebviewUrl, WebviewWindow}; +use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; @@ -67,6 +67,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) -> CommandChild { .sidecar("opencode") .unwrap() .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_CLIENT", "desktop") .args(["serve", &format!("--port={port}")]) .spawn() .expect("Failed to spawn opencode"); @@ -106,6 +107,8 @@ pub fn run() { let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_window_state::Builder::new().build()) + .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_process::init()) @@ -166,10 +169,15 @@ pub fn run() { None }; + let primary_monitor = app.primary_monitor().ok().flatten(); + let size = primary_monitor + .map(|m| m.size().to_logical(m.scale_factor())) + .unwrap_or(LogicalSize::new(1920, 1080)); + let mut window_builder = WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) .title("OpenCode") - .inner_size(800.0, 600.0) + .inner_size(size.width as f64, size.height as f64) .decorations(true) .zoom_hotkeys_enabled(true) .initialization_script(format!( diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index a0aaaae50..94ac84c64 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ }, "bundle": { "active": true, - "targets": ["deb", "rpm", "appimage", "dmg", "nsis"], + "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"], "externalBin": ["sidecars/opencode"], "createUpdaterArtifacts": true, diff --git a/packages/ui/package.json b/packages/ui/package.json index 5d2ac1644..e7bcbbf79 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.149", + "version": "1.0.150", "type": "module", "exports": { "./*": "./src/components/*.tsx", diff --git a/packages/ui/src/components/avatar.css b/packages/ui/src/components/avatar.css index 4e42e6f99..87be9a50a 100644 --- a/packages/ui/src/components/avatar.css +++ b/packages/ui/src/components/avatar.css @@ -1,5 +1,6 @@ [data-component="avatar"] { --avatar-bg: var(--color-surface-info-base); + --avatar-fg: var(--color-text-base); display: flex; align-items: center; justify-content: center; @@ -10,7 +11,7 @@ font-weight: 500; text-transform: uppercase; background-color: var(--avatar-bg); - color: oklch(from var(--avatar-bg) calc(l * 0.72) calc(c * 8) h); + color: var(--avatar-fg); } [data-component="avatar"][data-has-image] { diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index fb5798b08..ab7b0d0e2 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -4,11 +4,21 @@ export interface AvatarProps extends ComponentProps<"div"> { fallback: string src?: string background?: string + foreground?: string size?: "small" | "normal" | "large" } export function Avatar(props: AvatarProps) { - const [split, rest] = splitProps(props, ["fallback", "src", "background", "size", "class", "classList", "style"]) + const [split, rest] = splitProps(props, [ + "fallback", + "src", + "background", + "foreground", + "size", + "class", + "classList", + "style", + ]) const src = split.src // did this so i can zero it out to test fallback return ( <div @@ -23,6 +33,7 @@ export function Avatar(props: AvatarProps) { style={{ ...(typeof split.style === "object" ? split.style : {}), ...(!src && split.background ? { "--avatar-bg": split.background } : {}), + ...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}), }} > <Show when={src} fallback={split.fallback?.[0]}> diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1c7cd4f41..979906e26 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -88,8 +88,19 @@ flex-direction: column; flex: 1; overflow-y: auto; + + &:focus-visible { + outline: none; + } + } + &:focus-visible { + outline: none; } } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 40be8955c..79cd85532 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -47,6 +47,9 @@ const icons = { "layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, "layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, "dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`, + "circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`, + copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`, + check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 38dcb773b..132824164 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -98,16 +98,15 @@ display: block; } [data-slot="list-item-extra-icon"] { + display: block !important; color: var(--icon-strong-base) !important; } } &:active { background: var(--surface-raised-base-active); } - &:hover { - [data-slot="list-item-extra-icon"] { - color: var(--icon-strong-base) !important; - } + &:focus-visible { + outline: none; } } } diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 29b465c8c..7416cfd93 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -1,7 +1,6 @@ import { UserMessage } from "@opencode-ai/sdk/v2" -import { ComponentProps, createMemo, For, Match, Show, splitProps, Switch } from "solid-js" +import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" -import { Spinner } from "./spinner" import { Tooltip } from "@kobalte/core/tooltip" export function MessageNav( @@ -9,20 +8,15 @@ export function MessageNav( messages: UserMessage[] current?: UserMessage size: "normal" | "compact" - working?: boolean onMessageSelect: (message: UserMessage) => void }, ) { - const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"]) - const lastUserMessage = createMemo(() => { - return local.messages?.at(0) - }) + const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"]) const content = () => ( <ul role="list" data-component="message-nav" data-size={local.size} {...others}> <For each={local.messages}> {(message) => { - const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working) const handleClick = () => local.onMessageSelect(message) return ( @@ -35,14 +29,7 @@ export function MessageNav( </Match> <Match when={local.size === "normal"}> <button data-slot="message-nav-message-button" onClick={handleClick}> - <Switch> - <Match when={messageWorking()}> - <Spinner /> - </Match> - <Match when={true}> - <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> - </Match> - </Switch> + <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> <div data-slot="message-nav-title-preview" data-active={message.id === local.current?.id || undefined} @@ -64,7 +51,7 @@ export function MessageNav( return ( <Switch> <Match when={local.size === "compact"}> - <Tooltip openDelay={0} closeDelay={300} placement="left-start" gutter={-65} shift={-16} overlap> + <Tooltip openDelay={0} closeDelay={300} placement="right-start" gutter={-40} shift={-10} overlap> <Tooltip.Trigger as="div">{content()}</Tooltip.Trigger> <Tooltip.Portal> <Tooltip.Content data-slot="message-nav-tooltip"> diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index efa6c405b..06953168c 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" -import { Input } from "./input" +import { TextField } from "./text-field" interface SelectDialogProps<T> extends Omit<ListProps<T>, "filter">, @@ -55,7 +55,7 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) { <div data-component="select-dialog-input"> <div data-slot="select-dialog-input-container"> <Icon name="magnifying-glass" /> - <Input + <TextField ref={inputRef} autofocus variant="ghost" diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx index 132b813d2..1935a4f93 100644 --- a/packages/ui/src/components/session-message-rail.tsx +++ b/packages/ui/src/components/session-message-rail.tsx @@ -6,21 +6,12 @@ import "./session-message-rail.css" export interface SessionMessageRailProps extends ComponentProps<"div"> { messages: UserMessage[] current?: UserMessage - working?: boolean wide?: boolean onMessageSelect: (message: UserMessage) => void } export function SessionMessageRail(props: SessionMessageRailProps) { - const [local, others] = splitProps(props, [ - "messages", - "current", - "working", - "wide", - "onMessageSelect", - "class", - "classList", - ]) + const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"]) return ( <Show when={(local.messages?.length ?? 0) > 1}> @@ -39,7 +30,6 @@ export function SessionMessageRail(props: SessionMessageRailProps) { current={local.current} onMessageSelect={local.onMessageSelect} size="compact" - working={local.working} /> </div> <div data-slot="session-message-rail-full"> @@ -48,7 +38,6 @@ export function SessionMessageRail(props: SessionMessageRailProps) { current={local.current} onMessageSelect={local.onMessageSelect} size={local.wide ? "normal" : "compact"} - working={local.working} /> </div> </div> diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5e73c6772..f97a3224c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -42,10 +42,10 @@ export function SessionTurn( const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), + .sort((a, b) => a.id.localeCompare(b.id)), ) const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) + return userMessages()?.at(-1) }) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/text-field.css index 276e8069b..897050a63 100644 --- a/packages/ui/src/components/input.css +++ b/packages/ui/src/components/text-field.css @@ -40,6 +40,37 @@ letter-spacing: var(--letter-spacing-normal); } + [data-slot="input-wrapper"] { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-right: 4px; + + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background: var(--input-base); + + &:focus-within { + /* border/shadow-xs/select */ + box-shadow: + 0 0 0 3px var(--border-weak-selected), + 0 0 0 1px var(--border-selected), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), + 0 1px 3px 0 rgba(19, 16, 16, 0.12); + } + + &:has([data-invalid]) { + background: var(--surface-critical-weak); + border: 1px solid var(--border-critical-selected); + } + + &:not(:has([data-slot="input-copy-button"])) { + padding-right: 0; + } + } + [data-slot="input-input"] { color: var(--text-strong); @@ -47,12 +78,11 @@ height: 32px; padding: 2px 12px; align-items: center; - gap: 8px; - align-self: stretch; + flex: 1; + min-width: 0; - border-radius: var(--radius-md); - border: 1px solid var(--border-weak-base); - background: var(--input-base); + background: transparent; + border: none; /* text-14-regular */ font-family: var(--font-family-sans); @@ -64,19 +94,6 @@ &:focus { outline: none; - - /* border/shadow-xs/select */ - box-shadow: - 0 0 0 3px var(--border-weak-selected), - 0 0 0 1px var(--border-selected), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), - 0 1px 3px 0 rgba(19, 16, 16, 0.12); - } - - &[data-invalid] { - background: var(--surface-critical-weak); - border: 1px solid var(--border-critical-selected); } &::placeholder { @@ -84,6 +101,15 @@ } } + [data-slot="input-copy-button"] { + flex-shrink: 0; + color: var(--icon-base); + + &:hover { + color: var(--icon-strong-base); + } + } + [data-slot="input-error"] { color: var(--text-on-critical-base); diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/text-field.tsx index 8e2a115c6..63ffb2594 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -1,8 +1,10 @@ import { TextField as Kobalte } from "@kobalte/core/text-field" -import { Show, splitProps } from "solid-js" +import { createSignal, Show, splitProps } from "solid-js" import type { ComponentProps } from "solid-js" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" -export interface InputProps +export interface TextFieldProps extends ComponentProps<typeof Kobalte.Input>, Partial< Pick< @@ -20,13 +22,13 @@ export interface InputProps > { label?: string hideLabel?: boolean - hidden?: boolean description?: string error?: string variant?: "normal" | "ghost" + copyable?: boolean } -export function Input(props: InputProps) { +export function TextField(props: TextFieldProps) { const [local, others] = splitProps(props, [ "name", "defaultValue", @@ -39,12 +41,21 @@ export function Input(props: InputProps) { "readOnly", "class", "label", - "hidden", "hideLabel", "description", "error", "variant", + "copyable", ]) + const [copied, setCopied] = createSignal(false) + + async function handleCopy() { + const value = local.value ?? local.defaultValue ?? "" + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( <Kobalte data-component="input" @@ -57,7 +68,6 @@ export function Input(props: InputProps) { required={local.required} disabled={local.disabled} readOnly={local.readOnly} - style={{ height: local.hidden ? 0 : undefined }} validationState={local.validationState} > <Show when={local.label}> @@ -65,7 +75,20 @@ export function Input(props: InputProps) { {local.label} </Kobalte.Label> </Show> - <Kobalte.Input {...others} data-slot="input-input" class={local.class} /> + <div data-slot="input-wrapper"> + <Kobalte.Input {...others} data-slot="input-input" class={local.class} /> + <Show when={local.copyable}> + <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}> + <IconButton + type="button" + icon={copied() ? "check" : "copy"} + variant="ghost" + onClick={handleCopy} + data-slot="input-copy-button" + /> + </Tooltip> + </Show> + </div> <Show when={local.description}> <Kobalte.Description data-slot="input-description">{local.description}</Kobalte.Description> </Show> @@ -73,3 +96,8 @@ export function Input(props: InputProps) { </Kobalte> ) } + +/** @deprecated Use TextField instead */ +export const Input = TextField +/** @deprecated Use TextFieldProps instead */ +export type InputProps = TextFieldProps diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css new file mode 100644 index 000000000..fbc84f13c --- /dev/null +++ b/packages/ui/src/components/toast.css @@ -0,0 +1,203 @@ +[data-component="toast-region"] { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 400px; + width: 100%; + pointer-events: none; + + [data-slot="toast-list"] { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; + } +} + +[data-component="toast"] { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 16px 20px; + pointer-events: auto; + transition: all 150ms ease-out; + + border-radius: var(--radius-lg); + border: 1px solid var(--border-weak-base); + background: var(--surface-float-base); + color: var(--text-inverted-base); + box-shadow: var(--shadow-md); + + [data-slot="toast-inner"] { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &[data-opened] { + animation: toastPopIn 150ms ease-out; + } + + &[data-closed] { + animation: toastPopOut 100ms ease-in forwards; + } + + &[data-swipe="move"] { + transform: translateX(var(--kb-toast-swipe-move-x)); + } + + &[data-swipe="cancel"] { + transform: translateX(0); + transition: transform 200ms ease-out; + } + + &[data-swipe="end"] { + animation: toastSwipeOut 100ms ease-out forwards; + } + + /* &[data-variant="success"] { */ + /* border-color: var(--color-semantic-positive); */ + /* } */ + /**/ + /* &[data-variant="error"] { */ + /* border-color: var(--color-semantic-danger); */ + /* } */ + /**/ + /* &[data-variant="loading"] { */ + /* border-color: var(--color-semantic-info); */ + /* } */ + + [data-slot="toast-icon"] { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + [data-component="icon"] { + color: rgba(253, 252, 252, 0.94); + } + } + + [data-slot="toast-content"] { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + [data-slot="toast-title"] { + color: var(--text-inverted-strong); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-description"] { + color: var(--text-inverted-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-actions"] { + display: flex; + gap: 16px; + margin-top: 8px; + } + + [data-slot="toast-action"] { + background: none; + border: none; + padding: 0; + cursor: pointer; + + color: var(--text-inverted-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + &:hover { + text-decoration: underline; + } + + &:last-child { + color: var(--text-inverted-weak); + } + } + + [data-slot="toast-close-button"] { + flex-shrink: 0; + } + + [data-slot="toast-progress-track"] { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: var(--surface-base); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + overflow: hidden; + } + + [data-slot="toast-progress-fill"] { + height: 100%; + width: var(--kb-toast-progress-fill-width); + background-color: var(--color-primary); + transition: width 250ms linear; + } +} + +@keyframes toastPopIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toastPopOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +} + +@keyframes toastSwipeOut { + from { + transform: translateX(var(--kb-toast-swipe-end-x)); + } + to { + transform: translateX(100%); + } +} diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx new file mode 100644 index 000000000..5869f8a6b --- /dev/null +++ b/packages/ui/src/components/toast.tsx @@ -0,0 +1,160 @@ +import { Toast as Kobalte, toaster } from "@kobalte/core/toast" +import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import type { ComponentProps, JSX } from "solid-js" +import { Show } from "solid-js" +import { Portal } from "solid-js/web" +import { Icon, type IconProps } from "./icon" +import { IconButton } from "./icon-button" + +export interface ToastRegionProps extends ComponentProps<typeof Kobalte.Region> {} + +function ToastRegion(props: ToastRegionProps) { + return ( + <Portal> + <Kobalte.Region data-component="toast-region" {...props}> + <Kobalte.List data-slot="toast-list" /> + </Kobalte.Region> + </Portal> + ) +} + +export interface ToastRootComponentProps extends ToastRootProps { + class?: string + classList?: ComponentProps<"li">["classList"] + children?: JSX.Element +} + +function ToastRoot(props: ToastRootComponentProps) { + return ( + <Kobalte + data-component="toast" + classList={{ + ...(props.classList ?? {}), + [props.class ?? ""]: !!props.class, + }} + {...props} + /> + ) +} + +function ToastIcon(props: { name: IconProps["name"] }) { + return ( + <div data-slot="toast-icon"> + <Icon name={props.name} /> + </div> + ) +} + +function ToastContent(props: ComponentProps<"div">) { + return <div data-slot="toast-content" {...props} /> +} + +function ToastTitle(props: ToastTitleProps & ComponentProps<"div">) { + return <Kobalte.Title data-slot="toast-title" {...props} /> +} + +function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">) { + return <Kobalte.Description data-slot="toast-description" {...props} /> +} + +function ToastActions(props: ComponentProps<"div">) { + return <div data-slot="toast-actions" {...props} /> +} + +function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { + return <Kobalte.CloseButton data-slot="toast-close-button" as={IconButton} icon="close" variant="ghost" {...props} /> +} + +function ToastProgressTrack(props: ComponentProps<typeof Kobalte.ProgressTrack>) { + return <Kobalte.ProgressTrack data-slot="toast-progress-track" {...props} /> +} + +function ToastProgressFill(props: ComponentProps<typeof Kobalte.ProgressFill>) { + return <Kobalte.ProgressFill data-slot="toast-progress-fill" {...props} /> +} + +export const Toast = Object.assign(ToastRoot, { + Region: ToastRegion, + Icon: ToastIcon, + Content: ToastContent, + Title: ToastTitle, + Description: ToastDescription, + Actions: ToastActions, + CloseButton: ToastCloseButton, + ProgressTrack: ToastProgressTrack, + ProgressFill: ToastProgressFill, +}) + +export { toaster } + +export type ToastVariant = "default" | "success" | "error" | "loading" + +export interface ToastAction { + label: string + onClick: () => void +} + +export interface ToastOptions { + title?: string + description?: string + icon?: IconProps["name"] + variant?: ToastVariant + duration?: number + actions?: ToastAction[] +} + +export function showToast(options: ToastOptions | string) { + const opts = typeof options === "string" ? { description: options } : options + return toaster.show((props) => ( + <Toast toastId={props.toastId} duration={opts.duration} data-variant={opts.variant ?? "default"}> + <Show when={opts.icon}> + <Toast.Icon name={opts.icon!} /> + </Show> + <Toast.Content> + <Show when={opts.title}> + <Toast.Title>{opts.title}</Toast.Title> + </Show> + <Show when={opts.description}> + <Toast.Description>{opts.description}</Toast.Description> + </Show> + <Show when={opts.actions?.length}> + <Toast.Actions> + {opts.actions!.map((action) => ( + <button data-slot="toast-action" onClick={action.onClick}> + {action.label} + </button> + ))} + </Toast.Actions> + </Show> + </Toast.Content> + <Toast.CloseButton /> + </Toast> + )) +} + +export interface ToastPromiseOptions<T, U = unknown> { + loading?: JSX.Element + success?: (data: T) => JSX.Element + error?: (error: U) => JSX.Element +} + +export function showPromiseToast<T, U = unknown>( + promise: Promise<T> | (() => Promise<T>), + options: ToastPromiseOptions<T, U>, +) { + return toaster.promise(promise, (props) => ( + <Toast + toastId={props.toastId} + data-variant={props.state === "pending" ? "loading" : props.state === "fulfilled" ? "success" : "error"} + > + <Toast.Content> + <Toast.Description> + {props.state === "pending" && options.loading} + {props.state === "fulfilled" && options.success?.(props.data!)} + {props.state === "rejected" && options.error?.(props.error)} + </Toast.Description> + </Toast.Content> + <Toast.CloseButton /> + </Toast> + )) +} diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index 72ee269b2..637986249 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -7,6 +7,7 @@ max-width: 320px; border-radius: var(--radius-md); background-color: var(--surface-float-base); + color: var(--text-inverted-base); color: rgba(253, 252, 252, 0.94); padding: 2px 8px; border: 0.5px solid rgba(253, 252, 252, 0.2); diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index f9745918a..e3b373d4d 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps<T> { - items: T[] | ((filter: string) => Promise<T[]>) + items: (filter: string) => T[] | Promise<T[]> key: (item: T) => string filterKeys?: string[] current?: T @@ -22,7 +22,7 @@ export function useFilteredList<T>(props: FilteredListProps<T>) { () => store.filter, async (filter) => { const needle = filter?.toLowerCase() - const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || [] + const all = (await props.items(needle)) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 4c7f6e80b..d60082d93 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -21,7 +21,7 @@ @import "../components/provider-icon.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); -@import "../components/input.css" layer(components); +@import "../components/text-field.css" layer(components); @import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @@ -38,6 +38,7 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); +@import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); diff --git a/packages/util/package.json b/packages/util/package.json index 673e6f8df..496987ebb 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.149", + "version": "1.0.150", "private": true, "type": "module", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 80e38b70f..5b82ae78b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/web", "type": "module", - "version": "1.0.149", + "version": "1.0.150", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 77584f1a5..42df490cf 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -25,16 +25,18 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing | | [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs | | [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime | +| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs | --- ## Projects -| Name | Description | -| ------------------------------------------------------------- | ---------------------------------------------------------- | -| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| Name | Description | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| kimaki (https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| opencode.nvim (https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| portal (https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| opencode plugin template (https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | --- diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index b0e0397e1..a38df68f4 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -1,17 +1,17 @@ --- title: GitHub -description: Use opencode in GitHub issues and pull-requests. +description: Use OpenCode in GitHub issues and pull-requests. --- -opencode integrates with your GitHub workflow. Mention `/opencode` or `/oc` in your comment, and opencode will execute tasks within your GitHub Actions runner. +OpenCode integrates with your GitHub workflow. Mention `/opencode` or `/oc` in your comment, and OpenCode will execute tasks within your GitHub Actions runner. --- ## Features -- **Triage issues**: Ask opencode to look into an issue and explain it to you. -- **Fix and implement**: Ask opencode to fix an issue or implement a feature. And it will work in a new branch and submits a PR with all the changes. -- **Secure**: opencode runs inside your GitHub's runners. +- **Triage issues**: Ask OpenCode to look into an issue and explain it to you. +- **Fix and implement**: Ask OpenCode to fix an issue or implement a feature. And it will work in a new branch and submits a PR with all the changes. +- **Secure**: OpenCode runs inside your GitHub's runners. --- @@ -62,7 +62,7 @@ Or you can set it up manually. with: fetch-depth: 1 - - name: Run opencode + - name: Run OpenCode uses: sst/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -80,12 +80,12 @@ Or you can set it up manually. ## Configuration -- `model`: The model to use with opencode. Takes the format of `provider/model`. This is **required**. -- `share`: Whether to share the opencode session. Defaults to **true** for public repositories. -- `prompt`: Optional custom prompt to override the default behavior. Use this to customize how opencode processes requests. -- `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, opencode uses the installation access token from the opencode GitHub App, so commits, comments, and pull requests appear as coming from the app. +- `model`: The model to use with OpenCode. Takes the format of `provider/model`. This is **required**. +- `share`: Whether to share the OpenCode session. Defaults to **true** for public repositories. +- `prompt`: Optional custom prompt to override the default behavior. Use this to customize how OpenCode processes requests. +- `token`: Optional GitHub access token for performing operations such as creating comments, committing changes, and opening pull requests. By default, OpenCode uses the installation access token from the OpenCode GitHub App, so commits, comments, and pull requests appear as coming from the app. - Alternatively, you can use the GitHub Action runner's [built-in `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) without installing the opencode GitHub App. Just make sure to grant the required permissions in your workflow: + Alternatively, you can use the GitHub Action runner's [built-in `GITHUB_TOKEN`](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) without installing the OpenCode GitHub App. Just make sure to grant the required permissions in your workflow: ```yaml permissions: @@ -101,7 +101,7 @@ Or you can set it up manually. ## Custom prompts -Override the default prompt to customize opencode's behavior for your workflow. +Override the default prompt to customize OpenCode's behavior for your workflow. ```yaml title=".github/workflows/opencode.yml" - uses: sst/opencode/github@latest @@ -120,7 +120,7 @@ This is useful for enforcing specific review criteria, coding standards, or focu ## Examples -Here are some examples of how you can use opencode in GitHub. +Here are some examples of how you can use OpenCode in GitHub. - **Explain an issue** @@ -130,7 +130,7 @@ Here are some examples of how you can use opencode in GitHub. /opencode explain this issue ``` - opencode will read the entire thread, including all comments, and reply with a clear explanation. + OpenCode will read the entire thread, including all comments, and reply with a clear explanation. - **Fix an issue** @@ -140,7 +140,7 @@ Here are some examples of how you can use opencode in GitHub. /opencode fix this ``` - And opencode will create a new branch, implement the changes, and open a PR with the changes. + And OpenCode will create a new branch, implement the changes, and open a PR with the changes. - **Review PRs and make changes** @@ -150,18 +150,18 @@ Here are some examples of how you can use opencode in GitHub. Delete the attachment from S3 when the note is removed /oc ``` - opencode will implement the requested change and commit it to the same PR. + OpenCode will implement the requested change and commit it to the same PR. - **Review specific code lines** - Leave a comment directly on code lines in the PR's "Files" tab. opencode automatically detects the file, line numbers, and diff context to provide precise responses. + Leave a comment directly on code lines in the PR's "Files" tab. OpenCode automatically detects the file, line numbers, and diff context to provide precise responses. ``` [Comment on specific lines in Files tab] /oc add error handling here ``` - When commenting on specific lines, opencode receives: + When commenting on specific lines, OpenCode receives: - The exact file being reviewed - The specific lines of code - The surrounding diff context diff --git a/packages/web/src/content/docs/gitlab.mdx b/packages/web/src/content/docs/gitlab.mdx index 2490e5963..66c006c80 100644 --- a/packages/web/src/content/docs/gitlab.mdx +++ b/packages/web/src/content/docs/gitlab.mdx @@ -1,25 +1,25 @@ --- title: GitLab -description: Use opencode in GitLab issues and merge requests. +description: Use OpenCode in GitLab issues and merge requests. --- -opencode integrates with your GitLab workflow. -Mention `@opencode` in a comment, and opencode will execute tasks within your GitLab CI pipeline. +OpenCode integrates with your GitLab workflow. +Mention `@opencode` in a comment, and OpenCode will execute tasks within your GitLab CI pipeline. --- ## Features -- **Triage issues**: Ask opencode to look into an issue and explain it to you. -- **Fix and implement**: Ask opencode to fix an issue or implement a feature. +- **Triage issues**: Ask OpenCode to look into an issue and explain it to you. +- **Fix and implement**: Ask OpenCode to fix an issue or implement a feature. It will work create a new branch and raised a merge request with the changes. -- **Secure**: opencode runs on your GitLab runners. +- **Secure**: OpenCode runs on your GitLab runners. --- ## Setup -opencode runs in your GitLab CI/CD pipeline, here's what you'll need to set it up: +OpenCode runs in your GitLab CI/CD pipeline, here's what you'll need to set it up: :::tip Check out the [**GitLab docs**](https://docs.gitlab.com/user/duo_agent_platform/agent_assistant/) for up to date instructions. @@ -48,7 +48,7 @@ Check out the [**GitLab docs**](https://docs.gitlab.com/user/duo_agent_platform/ - apt-get install --yes glab - echo "Configuring glab" - echo $GITLAB_HOST - - echo "Creating opencode auth configuration" + - echo "Creating OpenCode auth configuration" - mkdir --parents ~/.local/share/opencode - | cat > ~/.local/share/opencode/auth.json << EOF @@ -61,10 +61,10 @@ Check out the [**GitLab docs**](https://docs.gitlab.com/user/duo_agent_platform/ EOF - echo "Configuring git" - git config --global user.email "[email protected]" - - git config --global user.name "Opencode" + - git config --global user.name "OpenCode" - echo "Testing glab" - glab issue list - - echo "Running Opencode" + - echo "Running OpenCode" - | opencode run " You are an AI assistant helping with GitLab operations. @@ -115,7 +115,7 @@ You can refer to the [GitLab CLI agents docs](https://docs.gitlab.com/user/duo_a ## Examples -Here are some examples of how you can use opencode in GitLab. +Here are some examples of how you can use OpenCode in GitLab. :::tip You can configure to use a different trigger phrase than `@opencode`. @@ -129,7 +129,7 @@ You can configure to use a different trigger phrase than `@opencode`. @opencode explain this issue ``` - opencode will read the issue and reply with a clear explanation. + OpenCode will read the issue and reply with a clear explanation. - **Fix an issue** @@ -139,7 +139,7 @@ You can configure to use a different trigger phrase than `@opencode`. @opencode fix this ``` - opencode will create a new branch, implement the changes, and open a merge request with the changes. + OpenCode will create a new branch, implement the changes, and open a merge request with the changes. - **Review merge requests** @@ -149,4 +149,4 @@ You can configure to use a different trigger phrase than `@opencode`. @opencode review this merge request ``` - opencode will review the merge request and provide feedback. + OpenCode will review the merge request and provide feedback. diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2f94d9e26..22c0e0578 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.0.149", + "version": "1.0.150", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/sst-env.d.ts b/sst-env.d.ts index 247b0ba81..2c182ec35 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -65,10 +65,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "Enterprise": { - "type": "sst.cloudflare.SolidStart" - "url": string - } "EnterpriseStorage": { "name": string "type": "sst.cloudflare.Bucket" @@ -120,6 +116,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "Teams": { + "type": "sst.cloudflare.SolidStart" + "url": string + } "Web": { "type": "sst.cloudflare.Astro" "url": string @@ -140,6 +140,10 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS5": { + "type": "sst.sst.Secret" + "value": string + } "ZenData": { "name": string "type": "sst.cloudflare.Bucket" |
