From decf2452c4474069b02931295170dcc695d80f4e Mon Sep 17 00:00:00 2001 From: rari404 <138394996+edlsh@users.noreply.github.com> Date: Sat, 13 Dec 2025 12:30:15 -0500 Subject: [PATCH 01/79] feat: add dockerfile language server (#5252) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/lsp/server.ts | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 5230117ee..353d4272d 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1470,4 +1470,49 @@ export namespace LSPServer { } }, } + + export const DockerfileLS: Info = { + id: "dockerfile", + extensions: [".dockerfile", "Dockerfile"], + root: async () => Instance.directory, + async spawn(root) { + let binary = Bun.which("docker-langserver") + const args: string[] = [] + if (!binary) { + const js = path.join( + Global.Path.bin, + "node_modules", + "dockerfile-language-server-nodejs", + "lib", + "server.js", + ) + if (!(await Bun.file(js).exists())) { + if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return + await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { + cwd: Global.Path.bin, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + stdout: "pipe", + stderr: "pipe", + stdin: "pipe", + }).exited + } + binary = BunProc.which() + args.push("run", js) + } + args.push("--stdio") + const proc = spawn(binary, args, { + cwd: root, + env: { + ...process.env, + BUN_BE_BUN: "1", + }, + }) + return { + process: proc, + } + }, + } } From 199bd8a9a2bb53fa17c86d15b4fe27d1fdceb807 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 17:30:48 +0000 Subject: [PATCH 02/79] chore: format code --- packages/opencode/src/lsp/server.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 353d4272d..8076edc24 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1479,13 +1479,7 @@ export namespace LSPServer { let binary = Bun.which("docker-langserver") const args: string[] = [] if (!binary) { - const js = path.join( - Global.Path.bin, - "node_modules", - "dockerfile-language-server-nodejs", - "lib", - "server.js", - ) + const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js") if (!(await Bun.file(js).exists())) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { From b46d4789fc26b36b4c2c3849253de6dc26931ebb Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 14 Dec 2025 02:33:10 +0900 Subject: [PATCH 03/79] docs: add oh-my-opencode to plugins list (#5481) --- packages/web/src/content/docs/ecosystem.mdx | 23 +++++++++++---------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 42df490cf..63fe8766f 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,17 +15,18 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [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 | +| Name | Description | +| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [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/angrister/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 | +| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | --- From b7581e01ea58b25708dd9a0d6b8cf30177a00119 Mon Sep 17 00:00:00 2001 From: "Jan-Niklas W." <6104311+niklas-wortmann@users.noreply.github.com> Date: Sat, 13 Dec 2025 11:33:31 -0600 Subject: [PATCH 04/79] docs: fix title for JetBrains ACP config file (#5479) --- packages/web/src/content/docs/acp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx index eb171b28c..9129db135 100644 --- a/packages/web/src/content/docs/acp.mdx +++ b/packages/web/src/content/docs/acp.mdx @@ -71,7 +71,7 @@ You can also bind a keyboard shortcut by editing your `keymap.json`: Add to your [JetBrains IDE](https://www.jetbrains.com/) acp.json according to the [documentation](https://www.jetbrains.com/help/ai-assistant/acp.html): -```json title="~/.config/zed/settings.json" +```json title="acp.json" { "agent_servers": { "OpenCode": { From 7434fbba8e4313449cd0e15b9b7c337f0a514efd Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 13 Dec 2025 17:34:07 +0000 Subject: [PATCH 05/79] chore: format code --- packages/web/src/content/docs/ecosystem.mdx | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 63fe8766f..03af6fd8c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -15,17 +15,17 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Plugins -| Name | Description | -| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | -| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | -| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | -| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | -| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | -| [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/angrister/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 | +| Name | Description | +| ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping | +| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities | +| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools | +| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits | +| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing | +| [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/angrister/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 | | [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible | --- From 7bf6f264e48b8e4fccb8310551ce7d6928c0a2c2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 13 Dec 2025 13:00:03 -0600 Subject: [PATCH 06/79] bump bun version & set flags this time --- bun.lock | 6 +++--- package.json | 4 ++-- packages/opencode/script/build.ts | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 5bc89cf56..d6bcc4e4d 100644 --- a/bun.lock +++ b/bun.lock @@ -477,7 +477,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@typescript/native-preview": "7.0.0-dev.20251207.1", @@ -1703,7 +1703,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2009,7 +2009,7 @@ "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="], - "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/package.json b/package.json index 39733b931..ca2a10f78 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.3", + "packageManager": "bun@1.3.4", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", @@ -20,7 +20,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.3", + "@types/bun": "1.3.4", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 5a6ac2584..a85fde9e2 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -117,6 +117,9 @@ for (const item of targets) { compile: { autoloadBunfig: false, autoloadDotenv: false, + //@ts-ignore (bun types aren't up to date) + autoloadTsconfig: true, + autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, outfile: `dist/${name}/bin/opencode`, execArgv: [`--user-agent=opencode/${Script.version}`, "--"], From b4ffaa21ec0576936fa24c72d6548bba6bdd9239 Mon Sep 17 00:00:00 2001 From: Github Action Date: Sat, 13 Dec 2025 19:01:20 +0000 Subject: [PATCH 07/79] Update Nix flake.lock and hashes --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index e28f98d05..4232eea86 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ=" + "nodeModules": "sha256-pLxjuCudEQilu9YAJ9lcyZIrX7THaDjQnI+TLhMuc3w=" } From f254cf76d919a96559706c92fa5af009ce699d63 Mon Sep 17 00:00:00 2001 From: Felipe Oduardo Sierra <420001+felipe@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:01:59 +0100 Subject: [PATCH 08/79] add ARM64 Docker image support (#5483) --- .github/workflows/publish.yml | 6 ++++++ packages/opencode/Dockerfile | 12 ++++++++++-- packages/opencode/script/publish.ts | 8 ++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 96b9280fb..9c44efe1b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,6 +64,12 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - uses: actions/setup-node@v4 with: node-version: "24" diff --git a/packages/opencode/Dockerfile b/packages/opencode/Dockerfile index 99f593581..f92b48a6d 100644 --- a/packages/opencode/Dockerfile +++ b/packages/opencode/Dockerfile @@ -1,10 +1,18 @@ -FROM alpine +FROM alpine AS base # Disable the runtime transpiler cache by default inside Docker containers. # On ephemeral containers, the cache is not useful ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH} RUN apk add libgcc libstdc++ ripgrep -ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-amd64 +COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-arm64 +COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode + +ARG TARGETARCH +FROM build-${TARGETARCH} RUN opencode --version ENTRYPOINT ["opencode"] diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index ff75bbb8d..72632992f 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -244,8 +244,8 @@ if (!Script.preview) { await $`cd ./dist/homebrew-tap && git push` const image = "ghcr.io/sst/opencode" - await $`docker build -t ${image}:${Script.version} .` - await $`docker push ${image}:${Script.version}` - await $`docker tag ${image}:${Script.version} ${image}:latest` - await $`docker push ${image}:latest` + const platforms = "linux/amd64,linux/arm64" + const tags = [`${image}:${Script.version}`, `${image}:latest`] + const tagFlags = tags.flatMap((t) => ["-t", t]) + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` } From 307af10c8bad1eb90288df5447275bb1b65cebc7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:15:11 -0600 Subject: [PATCH 09/79] fix: session turn scroll --- packages/ui/src/components/session-turn.css | 29 ++++--- packages/ui/src/components/session-turn.tsx | 87 +++++++++++---------- 2 files changed, 62 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index c4dd2b839..24eb1563b 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,5 +1,6 @@ [data-component="session-turn"] { /* flex: 1; */ + --scroll-y: 0px; height: 100%; min-height: 0; min-width: 0; @@ -26,18 +27,26 @@ align-items: flex-start; align-self: stretch; min-width: 0; - gap: 32px; + gap: clamp(8px, calc(42px - var(--scroll-y) * 0.48), 42px); } - [data-slot="session-turn-sticky-header"] { + [data-slot="session-turn-sticky-title"] { width: 100%; position: sticky; top: 0; background-color: var(--background-stronger); + z-index: 21; + /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ + } + + [data-slot="session-turn-response-trigger"] { + position: sticky; + top: 32px; + background-color: var(--background-stronger); z-index: 20; - display: flex; - flex-direction: column; - gap: 8px; + width: calc(100% + 9px); + margin-left: -9px; + padding-left: 9px; padding-bottom: 8px; } @@ -49,13 +58,8 @@ height: 32px; } - /* [data-slot="session-turn-message-content"] { */ - /* } */ - - [data-slot="session-turn-response-trigger"] { - width: calc(100% + 9px); - margin-left: -9px; - padding-left: 9px; + [data-slot="session-turn-message-content"] { + margin-top: -24px; } [data-slot="session-turn-message-title"] { @@ -292,6 +296,7 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; + /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 708ac5b83..361a5cac0 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,18 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -61,12 +50,15 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined const [contentRef, setContentRef] = createSignal() - const [stickyHeaderRef, setStickyHeaderRef] = createSignal() + const [stickyTitleRef, setStickyTitleRef] = createSignal() + const [stickyTriggerRef, setStickyTriggerRef] = createSignal() const [userScrolled, setUserScrolled] = createSignal(false) const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) + const [scrollY, setScrollY] = createSignal(0) function handleScroll() { if (!scrollRef) return + setScrollY(scrollRef.scrollTop) const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -88,15 +80,24 @@ export function SessionTurn( createResizeObserver(contentRef, () => { if (!scrollRef || userScrolled() || !working()) return - scrollRef.scrollTop = scrollRef.scrollHeight + requestAnimationFrame(() => { + if (!scrollRef) return + scrollRef.scrollTop = scrollRef.scrollHeight + }) }) - createResizeObserver(stickyHeaderRef, ({ height }) => { - setStickyHeaderHeight(height + 8) + createResizeObserver(stickyTitleRef, ({ height }) => { + const triggerHeight = stickyTriggerRef()?.offsetHeight ?? 0 + setStickyHeaderHeight(height + triggerHeight + 8) + }) + + createResizeObserver(stickyTriggerRef, ({ height }) => { + const titleHeight = stickyTitleRef()?.offsetHeight ?? 0 + setStickyHeaderHeight(titleHeight + height + 8) }) return ( -
+
@@ -250,8 +251,8 @@ export function SessionTurn( class={props.classes?.container} style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} > - {/* Sticky Header */} -
+ {/* Title (sticky) */} +
@@ -264,29 +265,31 @@ export function SessionTurn(
-
- -
-
- -
+
+ {/* User Message (non-sticky, scrolls under sticky header) */} +
+ +
+ {/* Trigger (sticky) */} +
+
{/* Response */} From a6e297baadc6c19a566a69da92c7fb2010f40977 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 06:51:24 -0600 Subject: [PATCH 10/79] feat(desktop): message history --- .../desktop/src/components/prompt-input.tsx | 161 ++++++++++++++++++ packages/ui/src/components/button.css | 2 +- packages/ui/src/components/session-turn.tsx | 3 +- packages/ui/src/styles/animations.css | 4 +- 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 70ee0a739..ec8267bf7 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -13,6 +13,7 @@ import { createMemo, } from "solid-js" import { createStore } from "solid-js/store" +import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" @@ -85,6 +86,69 @@ export const PromptInput: Component = (props) => { popoverIsOpen: false, }) + const MAX_HISTORY = 100 + const [history, setHistory] = makePersisted( + createStore<{ + entries: Prompt[] + }>({ + entries: [], + }), + { + name: "prompt-history.v1", + }, + ) + const [historyIndex, setHistoryIndex] = createSignal(-1) + const [savedPrompt, setSavedPrompt] = createSignal(null) + + const clonePromptParts = (prompt: Prompt): Prompt => + prompt.map((part) => + part.type === "text" + ? { ...part } + : { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + }, + ) + + const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + + const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(prompt) + session.prompt.set(prompt, length) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, length) + }) + } + + const getCaretLineState = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + const editorRect = editorRef.getBoundingClientRect() + const style = window.getComputedStyle(editorRef) + const paddingTop = parseFloat(style.paddingTop) || 0 + const paddingBottom = parseFloat(style.paddingBottom) || 0 + let lineHeight = parseFloat(style.lineHeight) + if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 + const scrollTop = editorRef.scrollTop + let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop + if (!Number.isFinite(relativeTop)) relativeTop = scrollTop + relativeTop = Math.max(0, relativeTop) + let caretHeight = rect.height + if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight + const relativeBottom = relativeTop + caretHeight + const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) + const threshold = Math.max(2, lineHeight / 2) + + return { + collapsed: selection.isCollapsed, + onFirstLine: relativeTop <= threshold, + onLastLine: contentHeight - relativeBottom <= threshold, + } + } + const [placeholder, setPlaceholder] = createSignal(Math.floor(Math.random() * PLACEHOLDERS.length)) onMount(() => { @@ -221,6 +285,11 @@ export const PromptInput: Component = (props) => { setStore("popoverIsOpen", false) } + if (historyIndex() >= 0) { + setHistoryIndex(-1) + setSavedPrompt(null) + } + session.prompt.set(rawParts, cursorPosition) } @@ -296,12 +365,100 @@ export const PromptInput: Component = (props) => { sessionID: session.id!, }) + const addToHistory = (prompt: Prompt) => { + const text = prompt + .map((p) => p.content) + .join("") + .trim() + if (!text) return + + const entry = clonePromptParts(prompt) + const lastEntry = history.entries[0] + if (lastEntry) { + const lastText = lastEntry.map((p) => p.content).join("") + if (lastText === text) return + } + + setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY)) + } + + const navigateHistory = (direction: "up" | "down") => { + const entries = history.entries + const current = historyIndex() + + if (direction === "up") { + if (entries.length === 0) return false + if (current === -1) { + setSavedPrompt(clonePromptParts(session.prompt.current())) + setHistoryIndex(0) + applyHistoryPrompt(entries[0], "start") + return true + } + if (current < entries.length - 1) { + const next = current + 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "start") + return true + } + return false + } + + if (current > 0) { + const next = current - 1 + setHistoryIndex(next) + applyHistoryPrompt(entries[next], "end") + return true + } + if (current === 0) { + setHistoryIndex(-1) + const saved = savedPrompt() + if (saved) { + applyHistoryPrompt(saved, "end") + setSavedPrompt(null) + return true + } + applyHistoryPrompt(DEFAULT_PROMPT, "end") + return true + } + + return false + } + const handleKeyDown = (event: KeyboardEvent) => { if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { onKeyDown(event) event.preventDefault() return } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (!collapsed) return + const cursorPos = getCursorPosition(editorRef) + const textLength = promptLength(session.prompt.current()) + const inHistory = historyIndex() >= 0 + const isStart = cursorPos === 0 + const isEnd = cursorPos === textLength + const atAbsoluteStart = onFirstLine && isStart + const atAbsoluteEnd = onLastLine && isEnd + const allowUp = (inHistory && isEnd) || atAbsoluteStart + const allowDown = (inHistory && isStart) || atAbsoluteEnd + + if (event.key === "ArrowUp") { + if (!allowUp) return + if (navigateHistory("up")) { + event.preventDefault() + } + return + } + + if (!allowDown) return + if (navigateHistory("down")) { + event.preventDefault() + } + return + } + if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } @@ -323,6 +480,10 @@ export const PromptInput: Component = (props) => { return } + addToHistory(prompt) + setHistoryIndex(-1) + setSavedPrompt(null) + let existing = session.info() if (!existing) { const created = await sdk.client.session.create() diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index c5bd2c696..7aba89b03 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -148,7 +148,7 @@ padding: 0 12px 0 8px; } - gap: 4px; + gap: 8px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 361a5cac0..07946ed79 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -81,7 +81,6 @@ export function SessionTurn( createResizeObserver(contentRef, () => { if (!scrollRef || userScrolled() || !working()) return requestAnimationFrame(() => { - if (!scrollRef) return scrollRef.scrollTop = scrollRef.scrollHeight }) }) @@ -266,7 +265,7 @@ export function SessionTurn(
- {/* User Message (non-sticky, scrolls under sticky header) */} + {/* User Message */}
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 0ae3493eb..3480976dd 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -5,7 +5,7 @@ @keyframes pulse-opacity { 0%, 100% { - opacity: 0; + opacity: 0.4; } 50% { opacity: 1; @@ -18,7 +18,7 @@ opacity: 0; } 50% { - opacity: 0.3; + opacity: 0.2; } } From d0789632b4cf8d29d67faf652d955868fc447208 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:12:32 -0600 Subject: [PATCH 11/79] fix(desktop): terminal light mode --- packages/desktop/src/components/terminal.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 15302f152..865d9b30f 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -1,8 +1,9 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/session" +import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void + const prefersDark = usePrefersDark() onMount(async () => { ghostty = await Ghostty.load() @@ -31,10 +33,17 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: "TX-02, monospace", allowTransparency: true, - theme: { - background: "#191515", - foreground: "#d4d4d4", - }, + theme: prefersDark() + ? { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + } + : { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + }, scrollback: 10_000, ghostty, }) From 5bcc93851c59829c00c5801c039170cc6e1f3d7a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 13 Dec 2025 15:12:41 -0600 Subject: [PATCH 12/79] chore: cleanup --- bun.lock | 1 + packages/desktop/package.json | 1 + packages/ui/src/components/session-turn.tsx | 73 ++++++++++++--------- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index d6bcc4e4d..096d45aeb 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 91e04af08..6753308c9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -37,6 +37,7 @@ "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/scroll": "2.1.3", "@solid-primitives/storage": "4.3.3", diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 07946ed79..692e869fe 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,7 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" @@ -49,56 +49,67 @@ export function SessionTurn( const working = createMemo(() => status()?.type !== "idle") let scrollRef: HTMLDivElement | undefined - const [contentRef, setContentRef] = createSignal() - const [stickyTitleRef, setStickyTitleRef] = createSignal() - const [stickyTriggerRef, setStickyTriggerRef] = createSignal() - const [userScrolled, setUserScrolled] = createSignal(false) - const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0) - const [scrollY, setScrollY] = createSignal(0) + const [state, setState] = createStore({ + contentRef: undefined as HTMLDivElement | undefined, + stickyTitleRef: undefined as HTMLDivElement | undefined, + stickyTriggerRef: undefined as HTMLDivElement | undefined, + userScrolled: false, + stickyHeaderHeight: 0, + scrollY: 0, + }) function handleScroll() { if (!scrollRef) return - setScrollY(scrollRef.scrollTop) + setState("scrollY", scrollRef.scrollTop) const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { - setUserScrolled(true) + setState("userScrolled", true) } } function handleInteraction() { if (working()) { - setUserScrolled(true) + setState("userScrolled", true) } } createEffect(() => { if (!working()) { - setUserScrolled(false) + setState("userScrolled", false) } }) - createResizeObserver(contentRef, () => { - if (!scrollRef || userScrolled() || !working()) return - requestAnimationFrame(() => { - scrollRef.scrollTop = scrollRef.scrollHeight - }) - }) + createResizeObserver( + () => state.contentRef, + () => { + if (!scrollRef || state.userScrolled || !working()) return + requestAnimationFrame(() => { + scrollRef.scrollTop = scrollRef.scrollHeight + }) + }, + ) - createResizeObserver(stickyTitleRef, ({ height }) => { - const triggerHeight = stickyTriggerRef()?.offsetHeight ?? 0 - setStickyHeaderHeight(height + triggerHeight + 8) - }) + createResizeObserver( + () => state.stickyTitleRef, + ({ height }) => { + const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", height + triggerHeight + 8) + }, + ) - createResizeObserver(stickyTriggerRef, ({ height }) => { - const titleHeight = stickyTitleRef()?.offsetHeight ?? 0 - setStickyHeaderHeight(titleHeight + height + 8) - }) + createResizeObserver( + () => state.stickyTriggerRef, + ({ height }) => { + const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0 + setState("stickyHeaderHeight", titleHeight + height + 8) + }, + ) return ( -
+
-
+
setState("contentRef", el)} onClick={handleInteraction}> {(message) => { const assistantMessages = createMemo(() => { @@ -237,7 +248,7 @@ export function SessionTurn( createEffect((prev) => { const isWorking = working() - if (prev && !isWorking && !userScrolled()) { + if (prev && !isWorking && !state.userScrolled) { setStore("stepsExpanded", false) } return isWorking @@ -248,10 +259,10 @@ export function SessionTurn( data-message={message().id} data-slot="session-turn-message-container" class={props.classes?.container} - style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }} + style={{ "--sticky-header-height": `${state.stickyHeaderHeight}px` }} > {/* Title (sticky) */} -
+
setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
@@ -270,7 +281,7 @@ export function SessionTurn(
{/* Trigger (sticky) */} -
+
setState("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
@@ -154,7 +146,7 @@ export default function Download() { Windows (x64)
- + Download
@@ -170,7 +162,7 @@ export default function Download() { Linux (.deb)
- + Download
@@ -186,7 +178,7 @@ export default function Download() { Linux (.rpm)
- + Download
From 7368342bab50f1ffc71be59a54348b8246893141 Mon Sep 17 00:00:00 2001 From: Martijn Baay Date: Mon, 15 Dec 2025 00:13:32 +0100 Subject: [PATCH 48/79] feat: add experimental.continue_loop_on_deny config option (#4729) Co-authored-by: Aiden Cline --- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/session/processor.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 42f6b11e9..333e19848 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -783,6 +783,7 @@ export namespace Config { .array(z.string()) .optional() .describe("Tools that should only be available to primary agents."), + continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"), }) .optional(), }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f1f7dd096..a1244d1df 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,6 +12,7 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { Config } from "@/config/config" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -49,6 +50,7 @@ export namespace SessionProcessor { }, async process(streamInput: StreamInput) { log.info("process") + const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined @@ -228,7 +230,7 @@ export namespace SessionProcessor { }) if (value.error instanceof Permission.RejectedError) { - blocked = true + blocked = shouldBreak } delete toolcalls[value.toolCallId] } From fc3ffb2bf9d194d05803aeae3fb766fe74bc4bf8 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 14 Dec 2025 23:14:05 +0000 Subject: [PATCH 49/79] chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9d0bbcc92..9dc057ba5 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1518,6 +1518,10 @@ export type Config = { * Tools that should only be available to primary agents. */ primary_tools?: Array + /** + * Continue the agent loop when a tool call is denied + */ + continue_loop_on_deny?: boolean } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 98c8b3586..372b0a63d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8279,6 +8279,10 @@ "items": { "type": "string" } + }, + "continue_loop_on_deny": { + "description": "Continue the agent loop when a tool call is denied", + "type": "boolean" } } } From fdf560c3434032dee4e078548019049813239cc5 Mon Sep 17 00:00:00 2001 From: Ravi Kumar <82090231+Raviguntakala@users.noreply.github.com> Date: Mon, 15 Dec 2025 07:20:54 +0530 Subject: [PATCH 50/79] fix(tui): --continue selects wrong session (#5513) --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 18a342818..69db202ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -218,7 +218,9 @@ function App() { let continued = false createEffect(() => { if (continued || sync.status !== "complete" || !args.continue) return - const match = sync.data.session.find((x) => x.parentID === undefined)?.id + const match = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .find((x) => x.parentID === undefined)?.id if (match) { continued = true route.navigate({ type: "session", sessionID: match }) From fed4776451eab0bd2490f2438094f0919e4fb677 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 14 Dec 2025 21:11:30 -0500 Subject: [PATCH 51/79] LLM cleanup (#5462) Co-authored-by: GitHub Action Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- .opencode/opencode.jsonc | 1 + packages/desktop/src/context/local.tsx | 2 +- packages/opencode/src/agent/agent.ts | 74 ++-- .../{session => agent}/prompt/compaction.txt | 0 .../opencode/src/agent/prompt/explore.txt | 18 + .../prompt/summary.txt} | 0 .../src/{session => agent}/prompt/title.txt | 4 +- packages/opencode/src/cli/cmd/agent.ts | 4 +- .../cli/cmd/tui/component/dialog-agent.tsx | 2 +- .../cmd/tui/component/prompt/autocomplete.tsx | 2 +- .../src/cli/cmd/tui/context/local.tsx | 2 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/session/compaction.ts | 96 ++--- packages/opencode/src/session/llm.ts | 184 ++++++++++ packages/opencode/src/session/message-v2.ts | 20 +- packages/opencode/src/session/processor.ts | 15 +- packages/opencode/src/session/prompt.ts | 339 ++++-------------- packages/opencode/src/session/summary.ts | 92 ++--- packages/opencode/src/session/system.ts | 30 +- packages/opencode/src/tool/bash.ts | 1 - packages/opencode/src/tool/registry.ts | 14 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 80 +++-- packages/sdk/openapi.json | 167 ++++----- 24 files changed, 548 insertions(+), 609 deletions(-) rename packages/opencode/src/{session => agent}/prompt/compaction.txt (100%) create mode 100644 packages/opencode/src/agent/prompt/explore.txt rename packages/opencode/src/{session/prompt/summarize.txt => agent/prompt/summary.txt} (100%) rename packages/opencode/src/{session => agent}/prompt/title.txt (84%) create mode 100644 packages/opencode/src/session/llm.ts diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index fe70e35fa..d5d97f4c9 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -10,4 +10,5 @@ "options": {}, }, }, + "mcp": {}, } diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 39fd1f987..181a4d247 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -78,7 +78,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const agent = (() => { - const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [store, setStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 94127e51c..ef007df13 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -2,18 +2,24 @@ import { Config } from "../config/config" import z from "zod" import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" -import PROMPT_GENERATE from "./generate.txt" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" import { mergeDeep } from "remeda" +import PROMPT_GENERATE from "./generate.txt" +import PROMPT_COMPACTION from "./prompt/compaction.txt" +import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SUMMARY from "./prompt/summary.txt" +import PROMPT_TITLE from "./prompt/title.txt" + export namespace Agent { export const Info = z .object({ name: z.string(), description: z.string().optional(), mode: z.enum(["subagent", "primary", "all"]), - builtIn: z.boolean(), + native: z.boolean().optional(), + hidden: z.boolean().optional(), topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), @@ -112,7 +118,8 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + hidden: true, }, explore: { name: "explore", @@ -124,30 +131,23 @@ export namespace Agent { ...defaultTools, }, description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, - prompt: [ - `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`, - ``, - `Your strengths:`, - `- Rapidly finding files using glob patterns`, - `- Searching code and text with powerful regex patterns`, - `- Reading and analyzing file contents`, - ``, - `Guidelines:`, - `- Use Glob for broad file pattern matching`, - `- Use Grep for searching file contents with regex`, - `- Use Read when you know the specific file path you need to read`, - `- Use Bash for file operations like copying, moving, or listing directory contents`, - `- Adapt your search approach based on the thoroughness level specified by the caller`, - `- Return file paths as absolute paths in your final response`, - `- For clear communication, avoid using emojis`, - `- Do not create any files, or run bash commands that modify the user's system state in any way`, - ``, - `Complete the user's search request efficiently and report your findings clearly.`, - ].join("\n"), + prompt: PROMPT_EXPLORE, options: {}, permission: agentPermission, mode: "subagent", - builtIn: true, + native: true, + }, + compaction: { + name: "compaction", + mode: "primary", + native: true, + hidden: true, + prompt: PROMPT_COMPACTION, + tools: { + "*": false, + }, + options: {}, + permission: agentPermission, }, build: { name: "build", @@ -155,7 +155,27 @@ export namespace Agent { options: {}, permission: agentPermission, mode: "primary", - builtIn: true, + native: true, + }, + title: { + name: "title", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_TITLE, + tools: {}, + }, + summary: { + name: "summary", + mode: "primary", + options: {}, + native: true, + hidden: true, + permission: agentPermission, + prompt: PROMPT_SUMMARY, + tools: {}, }, plan: { name: "plan", @@ -165,7 +185,7 @@ export namespace Agent { ...defaultTools, }, mode: "primary", - builtIn: true, + native: true, }, } for (const [key, value] of Object.entries(cfg.agent ?? {})) { @@ -181,7 +201,7 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, - builtIn: false, + native: false, } const { name, diff --git a/packages/opencode/src/session/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt similarity index 100% rename from packages/opencode/src/session/prompt/compaction.txt rename to packages/opencode/src/agent/prompt/compaction.txt diff --git a/packages/opencode/src/agent/prompt/explore.txt b/packages/opencode/src/agent/prompt/explore.txt new file mode 100644 index 000000000..5761077cb --- /dev/null +++ b/packages/opencode/src/agent/prompt/explore.txt @@ -0,0 +1,18 @@ +You are a file search specialist. You excel at thoroughly navigating and exploring codebases. + +Your strengths: +- Rapidly finding files using glob patterns +- Searching code and text with powerful regex patterns +- Reading and analyzing file contents + +Guidelines: +- Use Glob for broad file pattern matching +- Use Grep for searching file contents with regex +- Use Read when you know the specific file path you need to read +- Use Bash for file operations like copying, moving, or listing directory contents +- Adapt your search approach based on the thoroughness level specified by the caller +- Return file paths as absolute paths in your final response +- For clear communication, avoid using emojis +- Do not create any files, or run bash commands that modify the user's system state in any way + +Complete the user's search request efficiently and report your findings clearly. diff --git a/packages/opencode/src/session/prompt/summarize.txt b/packages/opencode/src/agent/prompt/summary.txt similarity index 100% rename from packages/opencode/src/session/prompt/summarize.txt rename to packages/opencode/src/agent/prompt/summary.txt diff --git a/packages/opencode/src/session/prompt/title.txt b/packages/opencode/src/agent/prompt/title.txt similarity index 84% rename from packages/opencode/src/session/prompt/title.txt rename to packages/opencode/src/agent/prompt/title.txt index e297dc460..f67aaa95b 100644 --- a/packages/opencode/src/session/prompt/title.txt +++ b/packages/opencode/src/agent/prompt/title.txt @@ -22,8 +22,8 @@ Your output must be: - The title should NEVER include "summarizing" or "generating" when generating a title - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. “hello”, “lol”, “whats up”, “hey”): - → create a title that reflects the user’s tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) +- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): + → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 812e97423..2cbcfbfe9 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -227,8 +227,8 @@ const AgentListCommand = cmd({ async fn() { const agents = await Agent.list() const sortedAgents = agents.sort((a, b) => { - if (a.builtIn !== b.builtIn) { - return a.builtIn ? -1 : 1 + if (a.native !== b.native) { + return a.native ? -1 : 1 } return a.name.localeCompare(b.name) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx index 65aaeb22b..365a22445 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx @@ -12,7 +12,7 @@ export function DialogAgent() { return { value: item.name, title: item.name, - description: item.builtIn ? "native" : item.description, + description: item.native ? "native" : item.description, } }), ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index c40aa114a..37e6ccda5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -184,7 +184,7 @@ export function Autocomplete(props: { const agents = createMemo(() => { const agents = sync.data.agent return agents - .filter((agent) => !agent.builtIn && agent.mode !== "primary") + .filter((agent) => !agent.hidden && agent.mode !== "primary") .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 6cc97e041..f04b79685 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const agent = iife(() => { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent")) + const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const [agentStore, setAgentStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 60ce2297b..d4755af17 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -858,7 +858,7 @@ export namespace Provider { return info } - export async function getLanguage(model: Model) { + export async function getLanguage(model: Model): Promise { const s = await state() const key = `${model.providerID}/${model.id}` if (s.models.has(key)) return s.models.get(key)! diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index f9d1b1c04..f8ed149ba 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1,22 +1,18 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" -import { wrapLanguageModel, type ModelMessage } from "ai" import { Session } from "." import { Identifier } from "../id/id" import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" -import { SystemPrompt } from "./system" import z from "zod" import { SessionPrompt } from "./prompt" import { Flag } from "../flag/flag" import { Token } from "../util/token" -import { Config } from "../config/config" import { Log } from "../util/log" -import { ProviderTransform } from "@/provider/transform" import { SessionProcessor } from "./processor" import { fn } from "@/util/fn" -import { mergeDeep, pipe } from "remeda" +import { Agent } from "@/agent/agent" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -90,24 +86,21 @@ export namespace SessionCompaction { parentID: string messages: MessageV2.WithParts[] sessionID: string - model: { - providerID: string - modelID: string - } - agent: string abort: AbortSignal auto: boolean }) { - const cfg = await Config.get() - const model = await Provider.getModel(input.model.providerID, input.model.modelID) - const language = await Provider.getLanguage(model) - const system = [...SystemPrompt.compaction(model.providerID)] + const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + const agent = await Agent.get("compaction") + const model = agent.model + ? await Provider.getModel(agent.model.providerID, agent.model.modelID) + : await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID) const msg = (await Session.updateMessage({ id: Identifier.ascending("message"), role: "assistant", parentID: input.parentID, sessionID: input.sessionID, - mode: input.agent, + mode: "compaction", + agent: "compaction", summary: true, path: { cwd: Instance.directory, @@ -120,7 +113,7 @@ export namespace SessionCompaction { reasoning: 0, cache: { read: 0, write: 0 }, }, - modelID: input.model.modelID, + modelID: model.id, providerID: model.providerID, time: { created: Date.now(), @@ -129,46 +122,18 @@ export namespace SessionCompaction { const processor = SessionProcessor.create({ assistantMessage: msg, sessionID: input.sessionID, - model: model, + model, abort: input.abort, }) const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - // set to 0, we handle loop - maxRetries: 0, - providerOptions: ProviderTransform.providerOptions( - model, - pipe({}, mergeDeep(ProviderTransform.options(model, input.sessionID)), mergeDeep(model.options)), - ), - headers: model.headers, - abortSignal: input.abort, - tools: model.capabilities.toolcall ? {} : undefined, + user: userMessage, + agent, + abort: input.abort, + sessionID: input.sessionID, + tools: {}, + system: [], messages: [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...MessageV2.toModelMessage( - input.messages.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(input.messages), { role: "user", content: [ @@ -179,28 +144,9 @@ export namespace SessionCompaction { ], }, ], - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, - }, + model, }) + if (result === "continue" && input.auto) { const continueMsg = await Session.updateMessage({ id: Identifier.ascending("message"), @@ -209,8 +155,8 @@ export namespace SessionCompaction { time: { created: Date.now(), }, - agent: input.agent, - model: input.model, + agent: userMessage.agent, + model: userMessage.model, }) await Session.updatePart({ id: Identifier.ascending("part"), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts new file mode 100644 index 000000000..97b8aae2b --- /dev/null +++ b/packages/opencode/src/session/llm.ts @@ -0,0 +1,184 @@ +import { Provider } from "@/provider/provider" +import { Log } from "@/util/log" +import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" +import { mergeDeep, pipe } from "remeda" +import { ProviderTransform } from "@/provider/transform" +import { Config } from "@/config/config" +import { Instance } from "@/project/instance" +import type { Agent } from "@/agent/agent" +import type { MessageV2 } from "./message-v2" +import { Plugin } from "@/plugin" +import { SystemPrompt } from "./system" +import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@/flag/flag" + +export namespace LLM { + const log = Log.create({ service: "llm" }) + + export const OUTPUT_TOKEN_MAX = 32_000 + + export type StreamInput = { + user: MessageV2.User + sessionID: string + model: Provider.Model + agent: Agent.Info + system: string[] + abort: AbortSignal + messages: ModelMessage[] + small?: boolean + tools: Record + retries?: number + } + + export type StreamOutput = StreamTextResult + + export async function stream(input: StreamInput) { + const l = log + .clone() + .tag("providerID", input.model.providerID) + .tag("modelID", input.model.id) + .tag("sessionID", input.sessionID) + .tag("small", (input.small ?? false).toString()) + .tag("agent", input.agent.name) + l.info("stream", { + modelID: input.model.id, + providerID: input.model.providerID, + }) + const [language, cfg] = await Promise.all([Provider.getLanguage(input.model), Config.get()]) + + const system = SystemPrompt.header(input.model.providerID) + system.push( + [ + // use agent prompt otherwise provider prompt + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // any custom prompt passed into this call + ...input.system, + // any custom prompt from last user message + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + + const params = await Plugin.trigger( + "chat.params", + { + sessionID: input.sessionID, + agent: input.agent, + model: input.model, + provider: Provider.getProvider(input.model.providerID), + message: input.user, + }, + { + temperature: input.model.capabilities.temperature + ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) + : undefined, + topP: input.agent.topP ?? ProviderTransform.topP(input.model), + options: pipe( + {}, + mergeDeep(ProviderTransform.options(input.model, input.sessionID)), + input.small ? mergeDeep(ProviderTransform.smallOptions(input.model)) : mergeDeep({}), + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + ), + }, + ) + + l.info("params", { + params, + }) + + const maxOutputTokens = ProviderTransform.maxOutputTokens( + input.model.api.npm, + params.options, + input.model.limit.output, + OUTPUT_TOKEN_MAX, + ) + + const tools = await resolveTools(input) + + return streamText({ + onError(error) { + l.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && tools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + }, + temperature: params.temperature, + topP: params.topP, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + maxOutputTokens, + abortSignal: input.abort, + headers: { + ...(input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : undefined), + ...input.model.headers, + }, + maxRetries: input.retries ?? 0, + messages: [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ], + model: wrapLanguageModel({ + model: language, + middleware: [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, + }) + } + + async function resolveTools(input: Pick) { + const enabled = pipe( + input.agent.tools, + mergeDeep(await ToolRegistry.enabled(input.agent)), + mergeDeep(input.user.tools ?? {}), + ) + for (const [key, value] of Object.entries(enabled)) { + if (value === false) delete input.tools[key] + } + return input.tools + } +} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1f4fffaa6..76162c797 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -348,7 +348,11 @@ export namespace MessageV2 { parentID: z.string(), modelID: z.string(), providerID: z.string(), + /** + * @deprecated + */ mode: z.string(), + agent: z.string(), path: z.object({ cwd: z.string(), root: z.string(), @@ -412,12 +416,7 @@ export namespace MessageV2 { }) export type WithParts = z.infer - export function toModelMessage( - input: { - info: Info - parts: Part[] - }[], - ): ModelMessage[] { + export function toModelMessage(input: WithParts[]): ModelMessage[] { const result: UIMessage[] = [] for (const msg of input) { @@ -461,6 +460,15 @@ export namespace MessageV2 { } if (msg.info.role === "assistant") { + if ( + msg.info.error && + !( + MessageV2.AbortedError.isInstance(msg.info.error) && + msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) + ) { + continue + } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a1244d1df..1d4d24303 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,5 +1,4 @@ import { MessageV2 } from "./message-v2" -import { streamText } from "ai" import { Log } from "@/util/log" import { Identifier } from "@/id/id" import { Session } from "." @@ -12,6 +11,7 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { LLM } from "./llm" import { Config } from "@/config/config" export namespace SessionProcessor { @@ -21,15 +21,6 @@ export namespace SessionProcessor { export type Info = Awaited> export type Result = Awaited> - export type StreamInput = Parameters[0] - - export type TBD = { - model: { - modelID: string - providerID: string - } - } - export function create(input: { assistantMessage: MessageV2.Assistant sessionID: string @@ -48,14 +39,14 @@ export namespace SessionProcessor { partFromToolCall(toolCallID: string) { return toolcalls[toolCallID] }, - async process(streamInput: StreamInput) { + async process(streamInput: LLM.StreamInput) { log.info("process") const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true while (true) { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} - const stream = streamText(streamInput) + const stream = await LLM.stream(streamInput) for await (const value of stream.fullStream) { input.abort.throwIfAborted() diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 51fb49af4..9a36c5c62 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -5,32 +5,22 @@ 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" import { Provider } from "../provider/provider" -import { - generateText, - type ModelMessage, - type Tool as AITool, - tool, - wrapLanguageModel, - stepCountIs, - jsonSchema, -} from "ai" +import { type Tool as AITool, tool, jsonSchema } from "ai" import { SessionCompaction } from "./compaction" import { Instance } from "../project/instance" import { Bus } from "../bus" import { ProviderTransform } from "../provider/transform" import { SystemPrompt } from "./system" import { Plugin } from "../plugin" - 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 { clone, mergeDeep, pipe } from "remeda" +import { mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" @@ -44,12 +34,13 @@ import { Command } from "../command" import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" -import { Config } from "../config/config" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" +import { LLM } from "./llm" +import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" // @ts-ignore @@ -96,8 +87,8 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), - system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), + system: z.string().optional(), parts: z.array( z.discriminatedUnion("type", [ MessageV2.TextPart.omit({ @@ -145,6 +136,20 @@ export namespace SessionPrompt { }) export type PromptInput = z.infer + export const prompt = fn(PromptInput, async (input) => { + const session = await Session.get(input.sessionID) + await SessionRevert.cleanup(session) + + const message = await createUserMessage(input) + await Session.touch(input.sessionID) + + if (input.noReply === true) { + return message + } + + return loop(input.sessionID) + }) + export async function resolvePromptParts(template: string): Promise { const parts: PromptInput["parts"] = [ { @@ -196,20 +201,6 @@ export namespace SessionPrompt { return parts } - export const prompt = fn(PromptInput, async (input) => { - const session = await Session.get(input.sessionID) - await SessionRevert.cleanup(session) - - const message = await createUserMessage(input) - await Session.touch(input.sessionID) - - if (input.noReply === true) { - return message - } - - return loop(input.sessionID) - }) - function start(sessionID: string) { const s = state() if (s[sessionID]) return @@ -291,7 +282,6 @@ export namespace SessionPrompt { }) const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) - const language = await Provider.getLanguage(model) const task = tasks.pop() // pending subtask @@ -304,6 +294,7 @@ export namespace SessionPrompt { parentID: lastUser.id, sessionID, mode: task.agent, + agent: task.agent, path: { cwd: Instance.directory, root: Instance.worktree, @@ -414,11 +405,6 @@ export namespace SessionPrompt { messages: msgs, parentID: lastUser.id, abort, - agent: lastUser.agent, - model: { - providerID: model.providerID, - modelID: model.id, - }, sessionID, auto: task.auto, }) @@ -442,7 +428,6 @@ export namespace SessionPrompt { } // normal processing - const cfg = await Config.get() const agent = await Agent.get(lastUser.agent) const maxSteps = agent.maxSteps ?? Infinity const isLastStep = step >= maxSteps @@ -450,12 +435,14 @@ export namespace SessionPrompt { messages: msgs, agent, }) + const processor = SessionProcessor.create({ assistantMessage: (await Session.updateMessage({ id: Identifier.ascending("message"), parentID: lastUser.id, role: "assistant", mode: agent.name, + agent: agent.name, path: { cwd: Instance.directory, root: Instance.worktree, @@ -478,12 +465,6 @@ export namespace SessionPrompt { model, abort, }) - const system = await resolveSystemPrompt({ - model, - agent, - system: lastUser.system, - isLastStep, - }) const tools = await resolveTools({ agent, sessionID, @@ -491,30 +472,6 @@ export namespace SessionPrompt { tools: lastUser.tools, processor, }) - const provider = await Provider.getProvider(model.providerID) - const params = await Plugin.trigger( - "chat.params", - { - sessionID: sessionID, - agent: lastUser.agent, - model: model, - provider, - message: lastUser, - }, - { - temperature: model.capabilities.temperature - ? (agent.temperature ?? ProviderTransform.temperature(model)) - : undefined, - topP: agent.topP ?? ProviderTransform.topP(model), - topK: ProviderTransform.topK(model), - options: pipe( - {}, - mergeDeep(ProviderTransform.options(model, sessionID, provider?.options)), - mergeDeep(model.options), - mergeDeep(agent.options), - ), - }, - ) if (step === 1) { SessionSummary.summarize({ @@ -523,135 +480,25 @@ export namespace SessionPrompt { }) } - // 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(sessionMessages), - ...(isLastStep - ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] - : []), - ] - const result = await processor.process({ - onError(error) { - log.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(input) { - const lower = input.toolCall.toolName.toLowerCase() - if (lower !== input.toolCall.toolName && tools[lower]) { - log.info("repairing tool call", { - tool: input.toolCall.toolName, - repaired: lower, - }) - return { - ...input.toolCall, - toolName: lower, - } - } - return { - ...input.toolCall, - input: JSON.stringify({ - tool: input.toolCall.toolName, - error: input.error.message, - }), - toolName: "invalid", - } - }, - headers: { - ...(model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": sessionID, - "x-opencode-request": lastUser.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : undefined), - ...model.headers, - }, - // set to 0, we handle loop - maxRetries: 0, - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - maxOutputTokens: ProviderTransform.maxOutputTokens( - model.api.npm, - params.options, - model.limit.output, - OUTPUT_TOKEN_MAX, - ), - abortSignal: abort, - providerOptions: ProviderTransform.providerOptions(model, params.options), - stopWhen: stepCountIs(1), - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - toolChoice: isLastStep ? "none" : undefined, - messages, - tools: model.capabilities.toolcall === false ? undefined : tools, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - prompt types are compatible at runtime - args.params.prompt = ProviderTransform.message(args.params.prompt, model) - } - // Transform tool schemas for provider compatibility - if (args.params.tools && Array.isArray(args.params.tools)) { - args.params.tools = args.params.tools.map((tool: any) => { - // Tools at middleware level have inputSchema, not parameters - if (tool.inputSchema && typeof tool.inputSchema === "object") { - // Transform the inputSchema for provider compatibility - return { - ...tool, - inputSchema: ProviderTransform.schema(model, tool.inputSchema), - } - } - // If no inputSchema, return tool unchanged - return tool - }) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: sessionID, - }, - }, + user: lastUser, + agent, + abort, + sessionID, + system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], + messages: [ + ...MessageV2.toModelMessage(msgs), + ...(isLastStep + ? [ + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] + : []), + ], + tools, + model, }) if (result === "stop") break continue @@ -675,33 +522,6 @@ export namespace SessionPrompt { return Provider.defaultModel() } - async function resolveSystemPrompt(input: { - system?: string - agent: Agent.Info - model: Provider.Model - isLastStep?: boolean - }) { - let system = SystemPrompt.header(input.model.providerID) - system.push( - ...(() => { - if (input.system) return [input.system] - if (input.agent.prompt) return [input.agent.prompt] - return SystemPrompt.provider(input.model) - })(), - ) - system.push(...(await SystemPrompt.environment())) - system.push(...(await SystemPrompt.custom())) - - if (input.isLastStep) { - system.push(MAX_STEPS) - } - - // max 2 system prompt messages for caching purposes - const [first, ...rest] = system - system = [first, rest.join("\n")] - return system - } - async function resolveTools(input: { agent: Agent.Info model: Provider.Model @@ -709,6 +529,7 @@ export namespace SessionPrompt { tools?: Record processor: SessionProcessor.Info }) { + using _ = log.time("resolveTools") const tools: Record = {} const enabledTools = pipe( input.agent.tools, @@ -778,7 +599,6 @@ export namespace SessionPrompt { }, }) } - for (const [key, item] of Object.entries(await MCP.tools())) { if (Wildcard.all(key, enabledTools) === false) continue const execute = item.execute @@ -857,7 +677,6 @@ export namespace SessionPrompt { created: Date.now(), }, tools: input.tools, - system: input.system, agent: agent.name, model: input.model ?? agent.model ?? (await lastModel(input.sessionID)), } @@ -1148,7 +967,7 @@ export namespace SessionPrompt { synthetic: true, }) } - const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.mode === "plan") + const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan") if (wasPlan && input.agent.name === "build") { userMessage.parts.push({ id: Identifier.ascending("part"), @@ -1216,6 +1035,7 @@ export namespace SessionPrompt { sessionID: input.sessionID, parentID: userMsg.id, mode: input.agent, + agent: input.agent, cost: 0, path: { cwd: Instance.directory, @@ -1510,28 +1330,24 @@ export namespace SessionPrompt { input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)) .length === 1 if (!isFirst) return - const cfg = await Config.get() - const small = - (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) - const language = await Provider.getLanguage(small) - const provider = await Provider.getProvider(small.providerID) - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, input.session.id, provider?.options)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) - await generateText({ - // use higher # for reasoning models since reasoning tokens eat up a lot of the budget - maxOutputTokens: small.capabilities.reasoning ? 3000 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + if (!agent) return + const result = await LLM.stream({ + agent, + user: input.message.info as MessageV2.User, + system: [], + small: true, + tools: {}, + model: await iife(async () => { + if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID) + return ( + (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID)) + ) + }), + abort: new AbortController().signal, + sessionID: input.session.id, + retries: 2, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user", content: "Generate a title for this conversation:\n", @@ -1555,32 +1371,19 @@ export namespace SessionPrompt { }, ]), ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.session.id, - }, - }, }) - .then((result) => { - if (result.text) - return Session.update(input.session.id, (draft) => { - const cleaned = result.text - .replace(/[\s\S]*?<\/think>\s*/g, "") - .split("\n") - .map((line) => line.trim()) - .find((line) => line.length > 0) - if (!cleaned) return + const text = await result.text.catch((err) => log.error("failed to generate title", { error: err })) + if (text) + return Session.update(input.session.id, (draft) => { + const cleaned = text + .replace(/[\s\S]*?<\/think>\s*/g, "") + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) + if (!cleaned) return - const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - draft.title = title - }) - }) - .catch((error) => { - log.error("failed to generate title", { error, model: small.id }) + const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + draft.title = title }) } } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 4761c9d2f..83519307a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -1,20 +1,21 @@ import { Provider } from "@/provider/provider" -import { Config } from "@/config/config" + import { fn } from "@/util/fn" import z from "zod" import { Session } from "." -import { generateText, type ModelMessage } from "ai" + import { MessageV2 } from "./message-v2" import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" -import { ProviderTransform } from "@/provider/transform" -import { SystemPrompt } from "./system" + import { Log } from "@/util/log" import path from "path" import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" -import { mergeDeep, pipe } from "remeda" + +import { LLM } from "./llm" +import { Agent } from "@/agent/agent" export namespace SessionSummary { const log = Log.create({ service: "session.summary" }) @@ -61,7 +62,6 @@ export namespace SessionSummary { } async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) { - const cfg = await Config.get() const messages = input.messages.filter( (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID), ) @@ -78,27 +78,17 @@ export namespace SessionSummary { const small = (await Provider.getSmallModel(assistantMsg.providerID)) ?? (await Provider.getModel(assistantMsg.providerID, assistantMsg.modelID)) - const language = await Provider.getLanguage(small) - - const options = pipe( - {}, - mergeDeep(ProviderTransform.options(small, assistantMsg.sessionID)), - mergeDeep(ProviderTransform.smallOptions(small)), - mergeDeep(small.options), - ) const textPart = msgWithParts.parts.find((p) => p.type === "text" && !p.synthetic) as MessageV2.TextPart if (textPart && !userMsg.summary?.title) { - const result = await generateText({ - maxOutputTokens: small.capabilities.reasoning ? 1500 : 20, - providerOptions: ProviderTransform.providerOptions(small, options), + const agent = await Agent.get("title") + const stream = await LLM.stream({ + agent, + user: userMsg, + tools: {}, + model: agent.model ? await Provider.getModel(agent.model.providerID, agent.model.modelID) : small, + small: true, messages: [ - ...SystemPrompt.title(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), { role: "user" as const, content: ` @@ -109,18 +99,14 @@ export namespace SessionSummary { `, }, ], - headers: small.headers, - model: language, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, }) - log.info("title", { title: result.text }) - userMsg.summary.title = result.text + const result = await stream.text + log.info("title", { title: result }) + userMsg.summary.title = result await Session.updateMessage(userMsg) } @@ -138,34 +124,30 @@ export namespace SessionSummary { } } } - const result = await generateText({ - model: language, - maxOutputTokens: 100, - providerOptions: ProviderTransform.providerOptions(small, options), + const summaryAgent = await Agent.get("summary") + const stream = await LLM.stream({ + agent: summaryAgent, + user: userMsg, + tools: {}, + model: summaryAgent.model + ? await Provider.getModel(summaryAgent.model.providerID, summaryAgent.model.modelID) + : small, + small: true, messages: [ - ...SystemPrompt.summarize(small.providerID).map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), ...MessageV2.toModelMessage(messages), { - role: "user", + role: "user" as const, content: `Summarize the above conversation according to your system prompts.`, }, ], - headers: small.headers, - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: assistantMsg.sessionID, - }, - }, - }).catch(() => {}) + abort: new AbortController().signal, + sessionID: userMsg.sessionID, + system: [], + retries: 3, + }) + const result = await stream.text if (result) { - userMsg.summary.body = result.text + userMsg.summary.body = result } } await Session.updateMessage(userMsg) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 3146110cf..e15185b38 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -14,8 +14,7 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" -import PROMPT_SUMMARIZE from "./prompt/summarize.txt" -import PROMPT_TITLE from "./prompt/title.txt" + import PROMPT_CODEX from "./prompt/codex.txt" import type { Provider } from "@/provider/provider" @@ -118,31 +117,4 @@ export namespace SystemPrompt { ) return Promise.all(found).then((result) => result.filter(Boolean)) } - - export function compaction(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_COMPACTION] - default: - return [PROMPT_COMPACTION] - } - } - - export function summarize(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_SUMMARIZE] - default: - return [PROMPT_SUMMARIZE] - } - } - - export function title(providerID: string) { - switch (providerID) { - case "anthropic": - return [PROMPT_ANTHROPIC_SPOOF.trim(), PROMPT_TITLE] - default: - return [PROMPT_TITLE] - } - } } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6b84d1bff..115d8f8b2 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -50,7 +50,6 @@ const parser = lazy(async () => { }) // TODO: we may wanna rename this tool so it works better on other shells - export const BashTool = Tool.define("bash", async () => { const shell = Shell.acceptable() log.info("bash tool using shell", { shell }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 7e440a78a..647c74267 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,8 +21,11 @@ import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" +import { Log } from "@/util/log" export namespace ToolRegistry { + const log = Log.create({ service: "tool.registry" }) + export const state = Instance.state(async () => { const custom = [] as Tool.Info[] const glob = new Bun.Glob("tool/*.{js,ts}") @@ -119,10 +122,13 @@ export namespace ToolRegistry { } return true }) - .map(async (t) => ({ - id: t.id, - ...(await t.init()), - })), + .map(async (t) => { + using _ = log.time(t.id) + return { + id: t.id, + ...(await t.init()), + } + }), ) return result } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 90df76c22..16fe07ae4 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1203,10 +1203,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1222,8 +1222,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, @@ -1289,10 +1289,10 @@ export class Session extends HeyApiClient { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts?: Array }, options?: Options, @@ -1308,8 +1308,8 @@ export class Session extends HeyApiClient { { in: "body", key: "model" }, { in: "body", key: "agent" }, { in: "body", key: "noReply" }, - { in: "body", key: "system" }, { in: "body", key: "tools" }, + { in: "body", key: "system" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9dc057ba5..31d5b8561 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -147,6 +147,7 @@ export type AssistantMessage = { modelID: string providerID: string mode: string + agent: string path: { cwd: string root: string @@ -475,6 +476,40 @@ export type EventPermissionReplied = { } } +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string + /** + * Unique identifier for the todo item + */ + id: string +} + +export type EventTodoUpdated = { + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + export type SessionStatus = | { type: "idle" @@ -511,40 +546,6 @@ export type EventSessionCompacted = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string - /** - * Unique identifier for the todo item - */ - id: string -} - -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type EventCommandExecuted = { type: "command.executed" properties: { @@ -745,11 +746,11 @@ export type Event = | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied + | EventFileEdited + | EventTodoUpdated | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventFileEdited - | EventTodoUpdated | EventCommandExecuted | EventSessionCreated | EventSessionUpdated @@ -1738,7 +1739,8 @@ export type Agent = { name: string description?: string mode: "subagent" | "primary" | "all" - builtIn: boolean + native?: boolean + hidden?: boolean topP?: number temperature?: number color?: string @@ -2801,10 +2803,10 @@ export type SessionPromptData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { @@ -2896,10 +2898,10 @@ export type SessionPromptAsyncData = { } agent?: string noReply?: boolean - system?: string tools?: { [key: string]: boolean } + system?: string parts: Array } path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 372b0a63d..21928684a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1997,9 +1997,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2009,6 +2006,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -2202,9 +2202,6 @@ "noReply": { "type": "boolean" }, - "system": { - "type": "string" - }, "tools": { "type": "object", "propertyNames": { @@ -2214,6 +2211,9 @@ "type": "boolean" } }, + "system": { + "type": "string" + }, "parts": { "type": "array", "items": { @@ -5193,6 +5193,9 @@ "mode": { "type": "string" }, + "agent": { + "type": "string" + }, "path": { "type": "object", "properties": { @@ -5251,6 +5254,7 @@ "modelID", "providerID", "mode", + "agent", "path", "cost", "tokens" @@ -6152,6 +6156,72 @@ }, "required": ["type", "properties"] }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + }, + "id": { + "description": "Unique identifier for the todo item", + "type": "string" + } + }, + "required": ["content", "status", "priority", "id"] + }, + "Event.todo.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "todo.updated" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } + } + }, + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, "SessionStatus": { "anyOf": [ { @@ -6255,72 +6325,6 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - }, - "id": { - "description": "Unique identifier for the todo item", - "type": "string" - } - }, - "required": ["content", "status", "priority", "id"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, "Event.command.executed": { "type": "object", "properties": { @@ -6886,6 +6890,12 @@ { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, { "$ref": "#/components/schemas/Event.session.status" }, @@ -6895,12 +6905,6 @@ { "$ref": "#/components/schemas/Event.session.compacted" }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, { "$ref": "#/components/schemas/Event.command.executed" }, @@ -8920,7 +8924,10 @@ "type": "string", "enum": ["subagent", "primary", "all"] }, - "builtIn": { + "native": { + "type": "boolean" + }, + "hidden": { "type": "boolean" }, "topP": { @@ -9001,7 +9008,7 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "builtIn", "permission", "tools", "options"] + "required": ["name", "mode", "permission", "tools", "options"] }, "MCPStatusConnected": { "type": "object", From 622caae9c99bd182d62ea016fafbacd211e113d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 21:12:23 -0500 Subject: [PATCH 52/79] fix desktop updater --- packages/tauri/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index a47b4a619..607b94134 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "plugins": { "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEYwMDM5Nzg5OUMzOUExMDQKUldRRW9UbWNpWmNEOENYT01CV0lhOXR1UFhpaXJsK1Z3aU9lZnNtNzE0TDROWVMwVW9XQnFOelkK", - "endpoints": ["https://github.com/brendonovich/opencode/releases/latest/download/latest.json"] + "endpoints": ["https://github.com/sst/opencode/releases/latest/download/latest.json"] } } } From e22af250760e99c3f4cc88f48ef430e60e43d0a7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 21:23:45 -0500 Subject: [PATCH 53/79] ci: fix test failures in CI by pre-populating models cache Tests were failing in CI because the models.json cache file doesn't exist and the data() macro fallback only works at build time, not runtime. The preload now pre-fetches models.json and disables the background refresh to prevent race conditions during test execution. --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/provider/models.ts | 2 ++ packages/opencode/test/preload.ts | 12 ++++++++++++ 3 files changed, 15 insertions(+) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ca1af6d84..d7a24708a 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_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") + export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c523725ec..c58638d28 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -4,6 +4,7 @@ import path from "path" import z from "zod" import { data } from "./models-macro" with { type: "macro" } import { Installation } from "../installation" +import { Flag } from "../flag/flag" export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) @@ -83,6 +84,7 @@ export namespace ModelsDev { } export async function refresh() { + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return const file = Bun.file(filepath) log.info("refreshing", { file, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index c2a294ab2..08316a23f 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -11,6 +11,18 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") +// Pre-fetch models.json so tests don't need the macro fallback +// Also write the cache version file to prevent global/index.ts from clearing the cache +const cacheDir = path.join(dir, "cache", "opencode") +await fs.mkdir(cacheDir, { recursive: true }) +await fs.writeFile(path.join(cacheDir, "version"), "14") +const response = await fetch("https://models.dev/api.json") +if (response.ok) { + await fs.writeFile(path.join(cacheDir, "models.json"), await response.text()) +} +// Disable models.dev refresh to avoid race conditions during tests +process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true" + // Clear provider env vars to ensure clean test state delete process.env["ANTHROPIC_API_KEY"] delete process.env["OPENAI_API_KEY"] From 2d63c22d1a3de36476c2cd8f948b985672374ddd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 14 Dec 2025 22:05:53 -0500 Subject: [PATCH 54/79] fix share link --- .../enterprise/src/routes/share/[shareID].tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 7cce15906..8fe6f0c1b 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -138,18 +138,13 @@ const getData = query(async (shareID) => { export default function () { const params = useParams() - const data = createAsync( - async () => { - if (!params.shareID) throw new Error("Missing shareID") - const now = Date.now() - const data = getData(params.shareID) - console.log("getData", Date.now() - now) - return data - }, - { - deferStream: true, - }, - ) + const data = createAsync(async () => { + if (!params.shareID) throw new Error("Missing shareID") + const now = Date.now() + const data = getData(params.shareID) + console.log("getData", Date.now() - now) + return data + }) createEffect(() => { console.log(data()) From ed33d82535cf23eb48a0c41dce96317aca85ef6e Mon Sep 17 00:00:00 2001 From: Mark Jaquith Date: Sun, 14 Dec 2025 22:06:04 -0500 Subject: [PATCH 55/79] feat(cli): auto-submit prompt when using --prompt flag (#4510) --- .../opencode/src/cli/cmd/tui/component/prompt/index.tsx | 6 +++++- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) 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 5cc757ac2..784c8648e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -44,6 +44,7 @@ export type PromptRef = { reset(): void blur(): void focus(): void + submit(): void } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] @@ -447,11 +448,14 @@ export function Prompt(props: PromptProps) { }) setStore("extmarkToPartIndex", new Map()) }, + submit() { + submit() + }, }) async function submit() { if (props.disabled) return - if (autocomplete.visible) return + if (autocomplete?.visible) return if (!store.prompt.input) return const trimmed = store.prompt.input.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index d0bb296eb..ecdf93c43 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -57,6 +57,7 @@ export function Home() { } else if (args.prompt) { prompt.set({ input: args.prompt, parts: [] }) once = true + prompt.submit() } }) const directory = useDirectory() From a68bee7878d78ac7d480d6fbbd3225759e695c61 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 03:46:40 -0600 Subject: [PATCH 56/79] fix(desktop): layout fixes --- packages/desktop/src/pages/layout.tsx | 4 ++-- packages/ui/src/components/button.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 52a3bd6ab..ef51361ba 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -321,7 +321,7 @@ export default function Layout(props: ParentProps) { } return (
@@ -379,7 +379,7 @@ export default function Layout(props: ParentProps) {
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 7aba89b03..800795e87 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,6 +9,7 @@ user-select: none; cursor: default; outline: none; + white-space: nowrap; &[data-variant="primary"] { background-color: var(--icon-strong-base); From 4a8e8f537ca688cca52674a619065f577cbd3f9b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 05:40:43 -0600 Subject: [PATCH 57/79] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 406 +++++++++++++++ .../desktop/src/components/dialog-model.tsx | 212 ++++++++ .../src/components/dialog-provider.tsx | 68 +++ .../desktop/src/components/prompt-input.tsx | 208 +------- packages/desktop/src/context/layout.tsx | 9 +- packages/desktop/src/context/local.tsx | 93 +++- packages/desktop/src/pages/layout.tsx | 480 +----------------- packages/opencode/src/provider/provider.ts | 4 + packages/ui/src/components/select-dialog.tsx | 11 +- packages/ui/src/components/session-turn.tsx | 23 +- 10 files changed, 784 insertions(+), 730 deletions(-) create mode 100644 packages/desktop/src/components/dialog-connect.tsx create mode 100644 packages/desktop/src/components/dialog-model.tsx create mode 100644 packages/desktop/src/components/dialog-provider.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx new file mode 100644 index 000000000..a44365069 --- /dev/null +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -0,0 +1,406 @@ +import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useLayout } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon } from "@opencode-ai/ui/icon" +import { showToast } from "@opencode-ai/ui/toast" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { iife } from "@opencode-ai/util/iife" +import { Link } from "@/components/link" + +export const DialogConnect: Component = () => { + const layout = useLayout() + const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() + const platform = usePlatform() + + 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 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 + 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 ( + { + if (open) { + layout.dialog.open("connect") + } else { + layout.dialog.close("connect") + } + }} + > + + + { + 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 + } + layout.dialog.open("provider") + }} + /> + + + + +
+
+ +
+ + + Login with Claude Pro/Max + + Connect {provider().name} + +
+
+
+ + +
Select login method for {provider().name}.
+
+ (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {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: providerID(), + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and + more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name}{" "} + models in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {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 ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {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 ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx new file mode 100644 index 000000000..9d36e0797 --- /dev/null +++ b/packages/desktop/src/components/dialog-model.tsx @@ -0,0 +1,212 @@ +import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { useLocal } from "@/context/local" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { iife } from "@opencode-ai/util/iife" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogModel: Component = () => { + const local = useLocal() + const layout = useLayout() + const providers = useProviders() + + return ( + + 0}> + {iife(() => { + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => + layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, + ), + ) + return ( + { + 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"]} + sortBy={(a, b) => a.name.localeCompare(b.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={ + + } + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ ) + })} +
+ + {iife(() => { + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + > + + Select model + + + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + layout.dialog.close("model") + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+ +
+ ) + })} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-provider.tsx new file mode 100644 index 000000000..56c791479 --- /dev/null +++ b/packages/desktop/src/components/dialog-provider.tsx @@ -0,0 +1,68 @@ +import { Component, Show } from "solid-js" +import { useLayout } from "@/context/layout" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Tag } from "@opencode-ai/ui/tag" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" + +export const DialogProvider: Component = () => { + const layout = useLayout() + const providers = useProviders() + + return ( + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + onOpenChange={(open) => { + if (open) { + layout.dialog.open("provider") + } else { + layout.dialog.close("provider") + } + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ ) +} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 114e6d49d..7c60a6d01 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,5 +1,5 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" @@ -9,21 +9,14 @@ import { useSDK } from "@/context/sdk" import { useNavigate } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { Tag } from "@opencode-ai/ui/tag" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useLayout } from "@/context/layout" -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 { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogModel } from "@/components/dialog-model" interface PromptInputProps { class?: string @@ -65,7 +58,6 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const layout = useLayout() - const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -624,201 +616,7 @@ export const PromptInput: Component = (props) => { - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => - layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, - ), - ) - return ( - { - 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"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - // 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={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - layout.dialog.close("model") - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
- Add more models from popular providers -
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
- Connect with Claude Pro/Max or API key -
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
+
ephemeral.dialog?.open), open(dialog: Dialog) { - batch(() => { - // if (dialog !== "connect") { - // setEphemeral("connect", {}) - // } - setEphemeral("dialog", "open", dialog) - }) + setEphemeral("dialog", "open", dialog) }, close(dialog: Dialog) { if (ephemeral.dialog.open === dialog) { diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 181a4d247..f841da1cc 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -1,12 +1,14 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo } from "solid-js" -import { uniqueBy } from "remeda" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" +import { makePersisted } from "@solid-primitives/storage" +import { DateTime } from "luxon" export type LocalFile = FileNode & Partial<{ @@ -108,30 +110,66 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const [store, setStore] = createStore<{ + const [store, setStore] = makePersisted( + createStore<{ + user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] + recent: ModelKey[] + }>({ + user: [], + recent: [], + }), + { name: "model.v1" }, + ) + + const [ephemeral, setEphemeral] = createStore<{ model: Record - recent: ModelKey[] }>({ model: {}, - recent: [], }) - const value = localStorage.getItem("model") - setStore("recent", JSON.parse(value ?? "[]")) - createEffect(() => { - localStorage.setItem("model", JSON.stringify(store.recent)) - }) - - const list = createMemo(() => + const available = 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)"), + user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id), })), ), ) + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + visible: + m.user?.visibility !== "hide" && + (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) || + store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"), + })), + ) + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) const fallbackModel = createMemo(() => { @@ -163,10 +201,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ throw new Error("No default model found") }) - const currentModel = createMemo(() => { + const current = createMemo(() => { const a = agent.current() const key = getFirstValidModel( - () => store.model[a.name], + () => ephemeral.model[a.name], () => a.model, fallbackModel, )! @@ -177,10 +215,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const cycle = (direction: 1 | -1) => { const recentList = recent() - const current = currentModel() - if (!current) return + const currentModel = current() + if (!currentModel) return - const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id) + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) if (index === -1) return let next = index + direction @@ -196,14 +236,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } + function updateVisibility(model: ModelKey, visibility: "show" | "hide") { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility: visibility }) + } + } + return { - current: currentModel, + current, recent, list, cycle, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { - setStore("model", agent.current().name, model ?? fallbackModel()) + setEphemeral("model", agent.current().name, model ?? fallbackModel()) if (options?.recent && model) { const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) if (uniq.length > 5) uniq.pop() @@ -211,6 +258,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, + show(model: ModelKey) { + updateVisibility(model, "show") + }, + hide(model: ModelKey) { + updateVisibility(model, "hide") + }, } })() diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index ef51361ba..63ee5b2aa 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,16 +1,4 @@ -import { - createEffect, - createMemo, - createSignal, - For, - Match, - onCleanup, - onMount, - ParentProps, - Show, - Switch, - type JSX, -} from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, getAvatarColors } from "@/context/layout" @@ -20,14 +8,13 @@ import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { Session, Project } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore, produce } from "solid-js/store" import { @@ -40,21 +27,14 @@ import { useDragDropContext, } from "@thisbeyond/solid-dnd" import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" -import { Tag } from "@opencode-ai/ui/tag" -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 { TextField } from "@opencode-ai/ui/text-field" -import { showToast, Toast } from "@opencode-ai/ui/toast" +import { useProviders } from "@/hooks/use-providers" +import { Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { Spinner } from "@opencode-ai/ui/spinner" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" +import { DialogProvider } from "@/components/dialog-provider" +import { DialogConnect } from "@/components/dialog-connect" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -576,454 +556,10 @@ export default function Layout(props: ParentProps) {
{props.children}
- x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - layout.dialog.connect(x.id) - }} - onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") - } - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
+
- {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 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 - 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 ( - { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") - } - }} - > - - - { - 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 - } - layout.dialog.open("provider") - }} - /> - - - - -
-
- -
- - - Login with Claude Pro/Max - - Connect {provider().name} - -
-
-
- - -
Select login method for {provider().name}.
-
- (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {i.label} -
- )} - -
- - -
-
- - Authorization in progress... -
-
-
- -
-
- - Authorization failed: {store.error} -
-
-
- - {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: providerID(), - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for - coding agents. -
-
- With a single API key you’ll get access to models such as Claude, GPT, Gemini, - GLM and more. -
-
- Visit{" "} - - opencode.ai/zen - {" "} - to collect your API key. -
-
-
- -
- Enter your {provider().name} API key to connect your account and use{" "} - {provider().name} models in OpenCode. -
-
-
-
- - - -
- ) - })} -
- - - - {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 ( -
-
- Visit this link to collect your - authorization code to connect your account and use {provider().name} models in - OpenCode. -
-
- - - -
- ) - })} -
- - {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 ( -
-
- Visit this link and enter the code - below to connect your account and use {provider().name} models in OpenCode. -
- -
- - Waiting for authorization... -
-
- ) - })} -
-
-
- -
-
- -
- ) - })} +
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d4755af17..b8d4dadbd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -392,6 +392,7 @@ export namespace Provider { status: z.enum(["alpha", "beta", "deprecated", "active"]), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()), + release_date: z.string(), }) .meta({ ref: "Model", @@ -470,6 +471,7 @@ export namespace Provider { }, interleaved: model.interleaved ?? false, }, + release_date: model.release_date, } } @@ -602,6 +604,8 @@ export namespace Provider { output: model.limit?.output ?? existingModel?.limit?.output ?? 0, }, headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", } parsed.models[modelID] = parsedModel } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 06953168c..68707536a 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" +import { Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" @@ -20,15 +20,6 @@ export function SelectDialog(props: SelectDialogProps) { const [filter, setFilter] = createSignal("") let listRef: ListRef | undefined - createEffect(() => { - if (!props.current) return - const key = props.key(props.current) - requestAnimationFrame(() => { - const element = document.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "center" }) - }) - }) - const handleSelect = (item: T | undefined, index: number) => { others.onSelect?.(item, index) closeButton.click() diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b50e4c8a0..a4a762fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -50,19 +50,16 @@ export function SessionTurn( let scrollRef: HTMLDivElement | undefined const [state, setState] = createStore({ - contentRef: undefined as HTMLDivElement | undefined, stickyTitleRef: undefined as HTMLDivElement | undefined, stickyTriggerRef: undefined as HTMLDivElement | undefined, userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, - autoScrolling: false, }) function handleScroll() { if (!scrollRef) return setState("scrollY", scrollRef.scrollTop) - if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -77,13 +74,9 @@ export function SessionTurn( } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return - setState("autoScrolling", true) + if (!scrollRef || state.userScrolled || !working()) return requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) - requestAnimationFrame(() => { - setState("autoScrolling", false) - }) }) } @@ -93,13 +86,6 @@ export function SessionTurn( } }) - createResizeObserver( - () => state.contentRef, - () => { - scrollToBottom() - }, - ) - createResizeObserver( () => state.stickyTitleRef, ({ height }) => { @@ -119,7 +105,7 @@ export function SessionTurn( return (
-
setState("contentRef", el)} onClick={handleInteraction}> +
{(message) => { const assistantMessages = createMemo(() => { @@ -221,6 +207,11 @@ export function SessionTurn( }) } + createEffect(() => { + lastPart() + scrollToBottom() + }) + const [store, setStore] = createStore({ status: rawStatus(), stepsExpanded: true, From 62ffeb3987ad1188e37141513bee7d1f3ce0dcd8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:06:07 -0600 Subject: [PATCH 58/79] fix(desktop): auto scroll --- packages/ui/src/components/session-turn.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a4a762fc6..196e0bdb6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -55,11 +55,13 @@ export function SessionTurn( userScrolled: false, stickyHeaderHeight: 0, scrollY: 0, + autoScrolling: false, }) function handleScroll() { if (!scrollRef) return setState("scrollY", scrollRef.scrollTop) + if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef const atBottom = scrollHeight - scrollTop - clientHeight < 50 if (!atBottom && working()) { @@ -74,9 +76,13 @@ export function SessionTurn( } function scrollToBottom() { - if (!scrollRef || state.userScrolled || !working()) return + if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return + setState("autoScrolling", true) requestAnimationFrame(() => { scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + requestAnimationFrame(() => { + setState("autoScrolling", false) + }) }) } From 2613f44961a73bc57db6662bfa1d0407515c497a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 06:39:08 -0600 Subject: [PATCH 59/79] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 74 ++++++++--------- .../desktop/src/components/dialog-model.tsx | 32 ++++---- ...rovider.tsx => dialog-select-provider.tsx} | 15 ++-- .../desktop/src/components/prompt-input.tsx | 9 +-- packages/desktop/src/context/dialog.tsx | 80 +++++++++++++++++++ packages/desktop/src/context/layout.tsx | 69 +--------------- .../desktop/src/pages/directory-layout.tsx | 7 +- packages/desktop/src/pages/layout.tsx | 16 +--- 8 files changed, 151 insertions(+), 151 deletions(-) rename packages/desktop/src/components/{dialog-provider.tsx => dialog-select-provider.tsx} (87%) create mode 100644 packages/desktop/src/context/dialog.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index a44365069..d482b3f50 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -1,6 +1,6 @@ import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" @@ -17,18 +17,19 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" import { iife } from "@opencode-ai/util/iife" import { Link } from "@/components/link" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogModel } from "./dialog-model" -export const DialogConnect: Component = () => { - const layout = useLayout() +export const DialogConnect: Component<{ provider: string }> = (props) => { + const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const platform = usePlatform() - const providerID = createMemo(() => layout.connect.provider()!) - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === providerID())!) + const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => - globalSync.data.provider_auth[providerID()] ?? [ + globalSync.data.provider_auth[props.provider] ?? [ { type: "api", label: "API key", @@ -61,7 +62,7 @@ export const DialogConnect: Component = () => { await globalSDK.client.provider.oauth .authorize( { - providerID: providerID(), + providerID: props.provider, method: index, }, { throwOnError: true }, @@ -116,55 +117,50 @@ export const DialogConnect: Component = () => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - layout.connect.complete() + dialog.replace(() => ) }, 500) } + function goBack() { + if (methods().length === 1) { + dialog.replace(() => ) + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("method", undefined) + return + } + if (store.method) { + setStore("method", undefined) + return + } + dialog.replace(() => ) + } + return ( { - if (open) { - layout.dialog.open("connect") - } else { - layout.dialog.close("connect") + if (!open) { + dialog.clear() } }} > - { - 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 - } - layout.dialog.open("provider") - }} - /> +
- +
- + Login with Claude Pro/Max Connect {provider().name} @@ -233,7 +229,7 @@ export const DialogConnect: Component = () => { setFormStore("error", undefined) await globalSDK.client.auth.set({ - providerID: providerID(), + providerID: props.provider, auth: { type: "api", key: apiKey, @@ -320,7 +316,7 @@ export const DialogConnect: Component = () => { setFormStore("error", undefined) const { error } = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), + providerID: props.provider, method: methodIndex(), code, }) @@ -369,12 +365,12 @@ export const DialogConnect: Component = () => { onMount(async () => { const result = await globalSDK.client.provider.oauth.callback({ - providerID: providerID(), + providerID: props.provider, method: methodIndex(), }) if (result.error) { // TODO: show error - layout.dialog.close("connect") + dialog.clear() return } await complete() diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx index 9d36e0797..7f90e1a78 100644 --- a/packages/desktop/src/components/dialog-model.tsx +++ b/packages/desktop/src/components/dialog-model.tsx @@ -1,6 +1,6 @@ import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useLocal } from "@/context/local" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Button } from "@opencode-ai/ui/button" @@ -10,10 +10,12 @@ import { List, ListRef } from "@opencode-ai/ui/list" import { iife } from "@opencode-ai/util/iife" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" -export const DialogModel: Component = () => { +export const DialogModel: Component<{ connectedProvider?: string }> = (props) => { const local = useLocal() - const layout = useLayout() + const dialog = useDialog() const providers = useProviders() return ( @@ -24,18 +26,14 @@ export const DialogModel: Component = () => { local.model .list() .filter((m) => m.visible) - .filter((m) => - layout.connect.state() === "complete" ? m.provider.id === layout.connect.provider() : true, - ), + .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)), ) return ( { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") + if (!open) { + dialog.clear() } }} title="Select model" @@ -66,7 +64,7 @@ export const DialogModel: Component = () => { class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} - onClick={() => layout.dialog.open("provider")} + onClick={() => dialog.replace(() => )} > Connect provider @@ -107,10 +105,8 @@ export const DialogModel: Component = () => { modal defaultOpen onOpenChange={(open) => { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") + if (!open) { + dialog.clear() } }} > @@ -130,7 +126,7 @@ export const DialogModel: Component = () => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - layout.dialog.close("model") + dialog.clear() }} > {(i) => ( @@ -163,7 +159,7 @@ export const DialogModel: Component = () => { }} onSelect={(x) => { if (!x) return - layout.dialog.connect(x.id) + dialog.replace(() => ) }} > {(i) => ( @@ -193,7 +189,7 @@ export const DialogModel: Component = () => { class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" icon="dot-grid" onClick={() => { - layout.dialog.open("provider") + dialog.replace(() => ) }} > View all providers diff --git a/packages/desktop/src/components/dialog-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx similarity index 87% rename from packages/desktop/src/components/dialog-provider.tsx rename to packages/desktop/src/components/dialog-select-provider.tsx index 56c791479..6dabdb8b4 100644 --- a/packages/desktop/src/components/dialog-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,13 +1,14 @@ import { Component, Show } from "solid-js" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogConnect } from "./dialog-connect" -export const DialogProvider: Component = () => { - const layout = useLayout() +export const DialogSelectProvider: Component = () => { + const dialog = useDialog() const providers = useProviders() return ( @@ -32,13 +33,11 @@ export const DialogProvider: Component = () => { }} onSelect={(x) => { if (!x) return - layout.dialog.connect(x.id) + dialog.replace(() => ) }} onOpenChange={(open) => { - if (open) { - layout.dialog.open("provider") - } else { - layout.dialog.close("provider") + if (!open) { + dialog.clear() } }} > diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 7c60a6d01..ca0ccf96a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -15,7 +15,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useLayout } from "@/context/layout" +import { useDialog } from "@/context/dialog" import { DialogModel } from "@/components/dialog-model" interface PromptInputProps { @@ -57,7 +57,7 @@ export const PromptInput: Component = (props) => { const sync = useSync() const local = useLocal() const session = useSession() - const layout = useLayout() + const dialog = useDialog() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -610,14 +610,11 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> - - - -
JSX.Element) + +export const { use: useDialog, provider: DialogProvider } = createSimpleContext({ + name: "Dialog", + init: () => { + const [store, setStore] = createStore({ + stack: [] as { + element: DialogElement + onClose?: () => void + }[], + }) + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && store.stack.length > 0) { + const current = store.stack.at(-1)! + current.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + e.preventDefault() + e.stopPropagation() + } + } + + createEffect(() => { + document.addEventListener("keydown", handleKeyDown, true) + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown, true) + }) + }) + + return { + get stack() { + return store.stack + }, + push(element: DialogElement, onClose?: () => void) { + setStore("stack", (s) => [...s, { element, onClose }]) + }, + pop() { + const current = store.stack.at(-1) + current?.onClose?.() + setStore("stack", store.stack.slice(0, -1)) + }, + replace(element: DialogElement, onClose?: () => void) { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", [{ element, onClose }]) + }, + clear() { + for (const item of store.stack) { + item.onClose?.() + } + setStore("stack", []) + }, + } + }, +}) + +export function DialogRoot(props: { children?: JSX.Element }) { + const dialog = useDialog() + return ( + <> + {props.children} + 0}> +
+ + {(item, index) => ( + + {typeof item.element === "function" ? item.element() : item.element} + + )} + +
+
+ + ) +} diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 587276c53..925bf4d4c 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ -import { createStore, produce } from "solid-js/store" -import { batch, createMemo, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -22,8 +22,6 @@ export function getAvatarColors(key?: string) { } } -type Dialog = "provider" | "model" | "connect" | "manage-models" - export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -45,22 +43,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "layout.v1", + name: "layout.v2", }, ) - const [ephemeral, setEphemeral] = createStore<{ - connect: { - provider?: string - state?: "pending" | "complete" | "error" - error?: string - } - dialog: { - open?: Dialog - } - }>({ - connect: {}, - dialog: {}, - }) + const usedColors = new Set() function pickAvailableColor(): AvatarColorKey { @@ -169,53 +155,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, - dialog: { - opened: createMemo(() => ephemeral.dialog?.open), - open(dialog: Dialog) { - setEphemeral("dialog", "open", dialog) - }, - close(dialog: Dialog) { - if (ephemeral.dialog.open === dialog) { - setEphemeral( - produce((state) => { - state.dialog.open = undefined - state.connect = {} - }), - ) - } - }, - connect(provider: string) { - setEphemeral( - produce((state) => { - state.dialog.open = "connect" - state.connect = { provider, state: "pending" } - }), - ) - }, - }, - connect: { - provider: createMemo(() => ephemeral.connect.provider), - state: createMemo(() => ephemeral.connect.state), - complete() { - setEphemeral( - produce((state) => { - state.dialog.open = "model" - state.connect.state = "complete" - }), - ) - }, - error(message: string) { - setEphemeral( - produce((state) => { - state.connect.state = "error" - state.connect.error = message - }), - ) - }, - clear() { - setEphemeral("connect", {}) - }, - }, } }, }) diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index c909a373d..1349f6ec0 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,6 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import { DialogProvider, DialogRoot } from "@/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -20,7 +21,11 @@ export default function Layout(props: ParentProps) { const sync = useSync() return ( - {props.children} + + + {props.children} + + ) })} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 63ee5b2aa..7b1d0e45a 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,8 +33,6 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" -import { DialogProvider } from "@/components/dialog-provider" -import { DialogConnect } from "@/components/dialog-connect" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -90,10 +88,6 @@ export default function Layout(props: ParentProps) { } } - async function connectProvider() { - layout.dialog.open("provider") - } - createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -494,7 +488,7 @@ export default function Layout(props: ParentProps) { class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px" size="large" icon="plus" - onClick={connectProvider} + // onClick={connectProvider} > Connect provider @@ -508,7 +502,7 @@ export default function Layout(props: ParentProps) { variant="ghost" size="large" icon="plus" - onClick={connectProvider} + // onClick={connectProvider} > Connect provider @@ -555,12 +549,6 @@ export default function Layout(props: ParentProps) {
{props.children}
- - - - - -
From 7ade6d386daeea120415b69f9df522001350db7b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 12:51:36 -0600 Subject: [PATCH 60/79] wip(desktop): progress --- packages/desktop/src/app.tsx | 57 ++++++++++--------- .../desktop/src/pages/directory-layout.tsx | 6 +- packages/desktop/src/pages/layout.tsx | 11 +++- packages/ui/src/components/dialog.tsx | 2 +- 4 files changed, 42 insertions(+), 34 deletions(-) diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index bf9dfd3b7..fd55b228e 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -1,20 +1,21 @@ import "@/index.css" +import { Show } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { Diff } from "@opencode-ai/ui/diff" -import { GlobalSyncProvider } from "./context/global-sync" +import { GlobalSyncProvider } from "@/context/global-sync" +import { LayoutProvider } from "@/context/layout" +import { GlobalSDKProvider } from "@/context/global-sdk" +import { SessionProvider } from "@/context/session" +import { NotificationProvider } from "@/context/notification" +import { DialogProvider } from "@/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" -import { LayoutProvider } from "./context/layout" -import { GlobalSDKProvider } from "./context/global-sdk" -import { SessionProvider } from "./context/session" -import { Show } from "solid-js" -import { NotificationProvider } from "./context/notification" declare global { interface Window { @@ -38,27 +39,29 @@ export function App() { - - - - - - - } /> - ( - - - - - - )} - /> - - - - + + + + + + + + } /> + ( + + + + + + )} + /> + + + + + diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 1349f6ec0..7b8d2ab9e 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -6,7 +6,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" -import { DialogProvider, DialogRoot } from "@/context/dialog" +import { DialogRoot } from "@/context/dialog" export default function Layout(props: ParentProps) { const params = useParams() @@ -22,9 +22,7 @@ export default function Layout(props: ParentProps) { return ( - - {props.children} - + {props.children} ) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 7b1d0e45a..c36cc234e 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,6 +33,8 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" +import { useDialog } from "@/context/dialog" +import { DialogSelectProvider } from "@/components/dialog-select-provider" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -48,6 +50,11 @@ export default function Layout(props: ParentProps) { const notification = useNotification() const navigate = useNavigate() const providers = useProviders() + const dialog = useDialog() + + function connectProvider() { + dialog.replace(() => ) + } function navigateToProject(directory: string | undefined) { if (!directory) return @@ -488,7 +495,7 @@ export default function Layout(props: ParentProps) { class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px" size="large" icon="plus" - // onClick={connectProvider} + onClick={connectProvider} > Connect provider @@ -502,7 +509,7 @@ export default function Layout(props: ParentProps) { variant="ghost" size="large" icon="plus" - // onClick={connectProvider} + onClick={connectProvider} > Connect provider diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 56053278d..aebb77885 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -14,7 +14,7 @@ export interface DialogProps extends DialogRootProps { classList?: ComponentProps<"div">["classList"] } -export function DialogRoot(props: DialogProps) { +function DialogRoot(props: DialogProps) { let trigger!: HTMLElement const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) From 4246cdb069502c96ab11e260eb36a07a0370b710 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:33:40 -0600 Subject: [PATCH 61/79] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 2 +- .../src/components/dialog-file-select.tsx | 52 ++++ .../src/components/dialog-manage-models.tsx | 65 +++++ .../src/components/dialog-model-unpaid.tsx | 133 +++++++++ .../desktop/src/components/dialog-model.tsx | 275 ++++++------------ .../src/components/dialog-select-provider.tsx | 101 ++++--- .../desktop/src/components/prompt-input.tsx | 9 +- packages/desktop/src/context/local.tsx | 7 +- packages/desktop/src/pages/session.tsx | 38 +-- packages/ui/src/components/dialog.css | 27 +- packages/ui/src/components/list.css | 38 +++ packages/ui/src/components/list.tsx | 141 +++++---- packages/ui/src/components/select-dialog.css | 44 --- packages/ui/src/components/select-dialog.tsx | 93 ------ packages/ui/src/components/session-turn.css | 2 - packages/ui/src/components/session-turn.tsx | 4 +- packages/ui/src/components/switch.css | 131 +++++++++ packages/ui/src/components/switch.tsx | 30 ++ packages/ui/src/hooks/use-filtered-list.tsx | 11 +- packages/ui/src/styles/index.css | 2 +- 20 files changed, 726 insertions(+), 479 deletions(-) create mode 100644 packages/desktop/src/components/dialog-file-select.tsx create mode 100644 packages/desktop/src/components/dialog-manage-models.tsx create mode 100644 packages/desktop/src/components/dialog-model-unpaid.tsx delete mode 100644 packages/ui/src/components/select-dialog.css delete mode 100644 packages/ui/src/components/select-dialog.tsx create mode 100644 packages/ui/src/components/switch.css create mode 100644 packages/ui/src/components/switch.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index d482b3f50..3a1e05f27 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -117,7 +117,7 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => ) + dialog.replace(() => ) }, 500) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx new file mode 100644 index 000000000..3afe06062 --- /dev/null +++ b/packages/desktop/src/components/dialog-file-select.tsx @@ -0,0 +1,52 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export const DialogFileSelect: Component<{ + onOpenChange?: (open: boolean) => void + onSelect?: (path: string) => void +}> = (props) => { + const local = useLocal() + let closeButton!: HTMLButtonElement + + return ( + + + Select file + + + + x} + onSelect={(x) => { + if (x) { + props.onSelect?.(x) + } + closeButton.click() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx new file mode 100644 index 000000000..2904f9a5b --- /dev/null +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -0,0 +1,65 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogManageModels: Component = () => { + const local = useLocal() + const dialog = useDialog() + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Manage models + + + Customize which models appear in the model selector. + + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + 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) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( +
+ {i.name} + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx new file mode 100644 index 000000000..d218770d9 --- /dev/null +++ b/packages/desktop/src/components/dialog-model-unpaid.tsx @@ -0,0 +1,133 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx index 7f90e1a78..e8f9df055 100644 --- a/packages/desktop/src/components/dialog-model.tsx +++ b/packages/desktop/src/components/dialog-model.tsx @@ -1,208 +1,95 @@ -import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { Component, createMemo, Show } from "solid-js" import { useLocal } from "@/context/local" import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { iife } from "@opencode-ai/util/iife" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { List } from "@opencode-ai/ui/list" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" +import { DialogManageModels } from "./dialog-manage-models" -export const DialogModel: Component<{ connectedProvider?: string }> = (props) => { +export const DialogModel: Component<{ provider?: string }> = (props) => { const local = useLocal() const dialog = useDialog() - const providers = useProviders() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) return ( - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)), - ) - return ( - { - if (!open) { - dialog.clear() - } - }} - 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"]} - sortBy={(a, b) => a.name.localeCompare(b.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={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + + + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.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, }) - }) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
Add more models from popular providers
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
+ closeButton.click() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} + + + + ) } diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 6dabdb8b4..1c54184bd 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,7 +1,8 @@ import { Component, Show } from "solid-js" import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -12,56 +13,66 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} onOpenChange={(open) => { if (!open) { dialog.clear() } }} > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
+ + Connect provider + + + + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+
+ ) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index ca0ccf96a..faecd9520 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -17,6 +17,8 @@ import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useDialog } from "@/context/dialog" import { DialogModel } from "@/components/dialog-model" +import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { class?: string @@ -58,6 +60,7 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const dialog = useDialog() + const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -610,7 +613,11 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> -
- x} + setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
-
- )} -
+ onSelect={(path) => session.layout.openTab("file://" + path)} + />
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 979906e26..fa5e1171e 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -59,9 +59,7 @@ [data-slot="dialog-header"] { display: flex; - /* height: 40px; */ - /* padding: 4px 4px 4px 8px; */ - padding: 20px; + padding: 16px; justify-content: space-between; align-items: center; flex-shrink: 0; @@ -80,7 +78,28 @@ } /* [data-slot="dialog-close-button"] {} */ } - /* [data-slot="dialog-description"] {} */ + + [data-slot="dialog-description"] { + display: flex; + padding: 16px; + padding-top: 0; + margin-top: -8px; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + align-self: stretch; + + color: var(--text-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + [data-slot="dialog-body"] { width: 100%; position: relative; diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 132824164..cd9e73d1d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -2,6 +2,43 @@ display: flex; flex-direction: column; gap: 20px; + overflow: hidden; + + [data-slot="list-search"] { + display: flex; + height: 40px; + flex-shrink: 0; + padding: 4px 10px 4px 16px; + align-items: center; + gap: 12px; + align-self: stretch; + + border-radius: var(--radius-md); + background: var(--surface-base); + + [data-slot="list-search-container"] { + display: flex; + align-items: center; + gap: 16px; + flex: 1 0 0; + + [data-slot="list-search-input"] { + width: 100%; + } + } + } + + [data-slot="list-scroll"] { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } + } [data-slot="list-empty-state"] { display: flex; @@ -41,6 +78,7 @@ [data-slot="list-header"] { display: flex; + z-index: 10; height: 28px; padding: 0 10px; justify-content: space-between; diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 013767e60..2923956a9 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -2,6 +2,13 @@ import { createEffect, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" +import { IconButton } from "./icon-button" +import { TextField } from "./text-field" + +export interface ListSearchProps { + placeholder?: string + autofocus?: boolean +} export interface ListProps extends FilteredListProps { class?: string @@ -10,6 +17,7 @@ export interface ListProps extends FilteredListProps { onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void activeIcon?: IconProps["name"] filter?: string + search?: ListSearchProps | boolean } export interface ListRef { @@ -19,23 +27,22 @@ export interface ListRef { export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { const [scrollRef, setScrollRef] = createSignal(undefined) + const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList({ - items: props.items, - key: props.key, - filterKeys: props.filterKeys, - current: props.current, - groupBy: props.groupBy, - sortBy: props.sortBy, - sortGroupsBy: props.sortGroupsBy, - }) + const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList(props) + + const searchProps = () => (typeof props.search === "object" ? props.search : {}) + const hasSearch = () => !!props.search createEffect(() => { - if (props.filter === undefined) return - onInput(props.filter) + if (props.filter !== undefined) { + onInput(props.filter) + } else if (hasSearch()) { + onInput(internalFilter()) + } }) createEffect(() => { @@ -92,52 +99,78 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
- 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for "{filter()}" -
+
+ +
+
+ +
- } - > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item, i) => ( - - )} - + + setInternalFilter("")} /> + +
+ +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}"
- )} - -
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item, i) => ( + + )} + +
+
+ )} +
+ +
) } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css deleted file mode 100644 index 9759174a6..000000000 --- a/packages/ui/src/components/select-dialog.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-slot="select-dialog-content"] { - width: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - gap: 20px; - padding: 0 10px; - - [data-slot="dialog-body"] { - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } - } -} - -[data-component="select-dialog-input"] { - display: flex; - height: 40px; - flex-shrink: 0; - padding: 4px 10px 4px 16px; - align-items: center; - gap: 12px; - align-self: stretch; - - border-radius: var(--radius-md); - background: var(--surface-base); - - [data-slot="select-dialog-input-container"] { - display: flex; - align-items: center; - gap: 16px; - flex: 1 0 0; - - /* [data-slot="select-dialog-icon"] {} */ - - [data-slot="select-dialog-input"] { - width: 100%; - } - } - - /* [data-slot="select-dialog-clear-button"] {} */ -} diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx deleted file mode 100644 index 68707536a..000000000 --- a/packages/ui/src/components/select-dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Show, type JSX, splitProps, createSignal } from "solid-js" -import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" -import { IconButton } from "./icon-button" -import { List, ListRef, ListProps } from "./list" -import { TextField } from "./text-field" - -interface SelectDialogProps - extends Omit, "filter">, - Pick { - title: string - placeholder?: string - actions?: JSX.Element -} - -export function SelectDialog(props: SelectDialogProps) { - const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) - let closeButton!: HTMLButtonElement - let inputRef: HTMLInputElement | undefined - const [filter, setFilter] = createSignal("") - let listRef: ListRef | undefined - - const handleSelect = (item: T | undefined, index: number) => { - others.onSelect?.(item, index) - closeButton.click() - } - - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - const handleOpenChange = (open: boolean) => { - if (!open) setFilter("") - props.onOpenChange?.(open) - } - - return ( - - - {others.title} - {others.actions} - - -
-
-
- - -
- - setFilter("")} /> - -
- - { - listRef = ref - }} - items={others.items} - key={others.key} - filterKeys={others.filterKeys} - current={others.current} - groupBy={others.groupBy} - sortBy={others.sortBy} - sortGroupsBy={others.sortGroupsBy} - emptyMessage={others.emptyMessage} - activeIcon={others.activeIcon} - filter={filter()} - onSelect={handleSelect} - onKeyEvent={others.onKeyEvent} - > - {others.children} - - -
-
- ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bc61318e3..0f218b515 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -37,7 +37,6 @@ top: 0; background-color: var(--background-stronger); z-index: 21; - /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ } [data-slot="session-turn-response-trigger"] { @@ -297,7 +296,6 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; - /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 196e0bdb6..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -60,6 +60,8 @@ export function SessionTurn( function handleScroll() { if (!scrollRef) return + // prevents scroll loops + if (working() && scrollRef.scrollTop < 100) return setState("scrollY", scrollRef.scrollTop) if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef @@ -79,7 +81,7 @@ export function SessionTurn( if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return setState("autoScrolling", true) requestAnimationFrame(() => { - scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { setState("autoScrolling", false) }) diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css new file mode 100644 index 000000000..c01e45d5f --- /dev/null +++ b/packages/ui/src/components/switch.css @@ -0,0 +1,131 @@ +[data-component="switch"] { + display: flex; + align-items: center; + gap: 8px; + cursor: default; + + [data-slot="switch-input"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + [data-slot="switch-control"] { + display: inline-flex; + align-items: center; + width: 28px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; + border: 1px solid var(--border-weak-base); + background: var(--surface-base); + transition: + background-color 150ms, + border-color 150ms; + } + + [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + box-sizing: content-box; + + border-radius: 2px; + border: 1px solid var(--border-base); + background: var(--icon-invert-base); + + /* shadows/shadow-xs */ + box-shadow: + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); + + transform: translateX(-1px); + transition: + transform 150ms, + background-color 150ms; + } + + [data-slot="switch-label"] { + user-select: none; + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-description"] { + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-error"] { + color: var(--text-error); + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-normal); + letter-spacing: var(--letter-spacing-normal); + } + + &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); + } + + &[data-checked] [data-slot="switch-control"] { + box-sizing: border-box; + border-color: var(--icon-strong-base); + background-color: var(--icon-strong-base); + } + + &[data-checked] [data-slot="switch-thumb"] { + border: none; + transform: translateX(12px); + background-color: var(--icon-invert-base); + } + + &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &[data-disabled] [data-slot="switch-control"] { + border-color: var(--border-disabled); + background-color: var(--surface-disabled); + } + + &[data-disabled] [data-slot="switch-thumb"] { + background-color: var(--icon-disabled); + } + + &[data-invalid] [data-slot="switch-control"] { + border-color: var(--border-error); + } + + &[data-readonly] { + cursor: default; + pointer-events: none; + } +} diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx new file mode 100644 index 000000000..af70dfb5c --- /dev/null +++ b/packages/ui/src/components/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as Kobalte } from "@kobalte/core/switch" +import { children, Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface SwitchProps extends ParentProps> { + hideLabel?: boolean + description?: string +} + +export function Switch(props: SwitchProps) { + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const resolved = children(() => local.children) + return ( + + + + + {resolved()} + + + + {local.description} + + + + + + + ) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e3b373d4d..76a5ae84f 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 { - items: (filter: string) => T[] | Promise + items: T[] | ((filter: string) => T[] | Promise) key: (item: T) => string filterKeys?: string[] current?: T @@ -19,10 +19,13 @@ export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) const [grouped, { refetch }] = createResource( - () => store.filter, - async (filter) => { + () => ({ + filter: store.filter, + items: typeof props.items === "function" ? undefined : props.items, + }), + async ({ filter, items }) => { const needle = filter?.toLowerCase() - const all = (await props.items(needle)) || [] + const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ba2c954bc..3f8838a7a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -30,8 +30,8 @@ @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); -@import "../components/select-dialog.css" layer(components); @import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From dda579c8ad30f81ade458769971d85ff7afee64c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:32:14 -0600 Subject: [PATCH 62/79] wip(desktop): progress --- packages/desktop/src/app.tsx | 2 +- .../desktop/src/components/dialog-connect.tsx | 517 +++++++++--------- .../src/components/dialog-file-select.tsx | 52 -- .../src/components/dialog-manage-models.tsx | 86 ++- .../src/components/dialog-model-unpaid.tsx | 133 ----- .../desktop/src/components/dialog-model.tsx | 95 ---- .../src/components/dialog-select-file.tsx | 44 ++ .../components/dialog-select-model-unpaid.tsx | 119 ++++ .../src/components/dialog-select-model.tsx | 85 +++ .../src/components/dialog-select-provider.tsx | 108 ++-- .../desktop/src/components/prompt-input.tsx | 10 +- .../desktop/src/pages/directory-layout.tsx | 2 +- packages/desktop/src/pages/layout.tsx | 2 +- packages/desktop/src/pages/session.tsx | 15 +- packages/ui/src/components/dialog.css | 2 + packages/ui/src/components/dialog.tsx | 123 ++--- .../{desktop => ui}/src/context/dialog.tsx | 37 +- packages/ui/src/context/index.ts | 1 + 18 files changed, 649 insertions(+), 784 deletions(-) delete mode 100644 packages/desktop/src/components/dialog-file-select.tsx delete mode 100644 packages/desktop/src/components/dialog-model-unpaid.tsx delete mode 100644 packages/desktop/src/components/dialog-model.tsx create mode 100644 packages/desktop/src/components/dialog-select-file.tsx create mode 100644 packages/desktop/src/components/dialog-select-model-unpaid.tsx create mode 100644 packages/desktop/src/components/dialog-select-model.tsx rename packages/{desktop => ui}/src/context/dialog.tsx (70%) diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index fd55b228e..a49dac9aa 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -11,7 +11,7 @@ import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { SessionProvider } from "@/context/session" import { NotificationProvider } from "@/context/notification" -import { DialogProvider } from "@/context/dialog" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" import Layout from "@/pages/layout" import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index 3a1e05f27..f61221f72 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -1,10 +1,10 @@ -import { Component, createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useGlobalSync } from "@/context/global-sync" import { useGlobalSDK } from "@/context/global-sdk" import { usePlatform } from "@/context/platform" -import { ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { Dialog } from "@opencode-ai/ui/dialog" import { List, ListRef } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" @@ -18,14 +18,13 @@ import { IconName } from "@opencode-ai/ui/icons/provider" import { iife } from "@opencode-ai/util/iife" import { Link } from "@/components/link" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogModel } from "./dialog-model" +import { DialogSelectModel } from "./dialog-select-model" -export const DialogConnect: Component<{ provider: string }> = (props) => { +export function DialogConnect(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() const platform = usePlatform() - const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const methods = createMemo( () => @@ -37,19 +36,19 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { ], ) const [store, setStore] = createStore({ - method: undefined as undefined | ProviderAuthMethod, + methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, state: "pending" as undefined | "pending" | "complete" | "error", error: undefined as string | undefined, }) - const methodIndex = createMemo(() => methods().findIndex((x) => x.label === store.method?.label)) + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) async function selectMethod(index: number) { const method = methods()[index] setStore( produce((draft) => { - draft.method = method + draft.methodIndex = index draft.authorization = undefined draft.state = undefined draft.error = undefined @@ -101,7 +100,6 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) onCleanup(() => { document.removeEventListener("keydown", handleKey) @@ -117,8 +115,8 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => ) - }, 500) + dialog.replace(() => ) + }, 1000) } function goBack() { @@ -128,275 +126,258 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { } if (store.authorization) { setStore("authorization", undefined) - setStore("method", undefined) + setStore("methodIndex", undefined) return } - if (store.method) { - setStore("method", undefined) + if (store.methodIndex) { + setStore("methodIndex", undefined) return } dialog.replace(() => ) } return ( - { - if (!open) { - dialog.clear() - } - }} - > - - - - - - - -
-
- -
- - - Login with Claude Pro/Max - - Connect {provider().name} - -
-
-
+ }> +
+
+ +
- -
Select login method for {provider().name}.
-
- (listRef = ref)} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {i.label} -
- )} - -
- - -
-
- - Authorization in progress... -
-
-
- -
-
- - Authorization failed: {store.error} -
-
-
- - {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: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- OpenCode Zen gives you access to a curated set of reliable optimized models for coding - agents. -
-
- With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and - more. -
-
- Visit{" "} - - opencode.ai/zen - {" "} - to collect your API key. -
-
-
- -
- Enter your {provider().name} API key to connect your account and use {provider().name}{" "} - models in OpenCode. -
-
-
-
- - - -
- ) - })} -
- - - - {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: props.provider, - method: methodIndex(), - code, - }) - if (!error) { - await complete() - return - } - setFormStore("error", "Invalid authorization code") - } - - return ( -
-
- Visit this link to collect your authorization - code to connect your account and use {provider().name} models in OpenCode. -
-
- - - -
- ) - })} -
- - {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: props.provider, - method: methodIndex(), - }) - if (result.error) { - // TODO: show error - dialog.clear() - return - } - await complete() - }) - - return ( -
-
- Visit this link and enter the code below to - connect your account and use {provider().name} models in OpenCode. -
- -
- - Waiting for authorization... -
-
- ) - })} -
-
+ + Login with Claude Pro/Max + Connect {provider().name}
- +
+ + +
Select login method for {provider().name}.
+
+ (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {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: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name} models + in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {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: props.provider, + method: store.methodIndex, + code, + }) + if (!error) { + await complete() + return + } + setFormStore("error", "Invalid authorization code") + } + + return ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {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: props.provider, + method: store.methodIndex, + }) + if (result.error) { + // TODO: show error + dialog.clear() + return + } + await complete() + }) + + return ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx deleted file mode 100644 index 3afe06062..000000000 --- a/packages/desktop/src/components/dialog-file-select.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Component } from "solid-js" -import { useLocal } from "@/context/local" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { FileIcon } from "@opencode-ai/ui/file-icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" - -export const DialogFileSelect: Component<{ - onOpenChange?: (open: boolean) => void - onSelect?: (path: string) => void -}> = (props) => { - const local = useLocal() - let closeButton!: HTMLButtonElement - - return ( - - - Select file - - - - x} - onSelect={(x) => { - if (x) { - props.onSelect?.(x) - } - closeButton.click() - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
- )} -
-
-
- ) -} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx index 2904f9a5b..de1c3cb15 100644 --- a/packages/desktop/src/components/dialog-manage-models.tsx +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -1,6 +1,5 @@ import { Component } from "solid-js" import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" import { popularProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -8,58 +7,41 @@ import { Switch } from "@opencode-ai/ui/switch" export const DialogManageModels: Component = () => { const local = useLocal() - const dialog = useDialog() - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Manage models - - - Customize which models appear in the model selector. - - `${x?.provider?.id}:${x?.id}`} - items={local.model.list()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - 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) => { - if (!x) return - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) - }} - > - {(i) => ( -
- {i.name} - { - local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) - }} - /> -
- )} -
-
+ + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + 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) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( +
+ {i.name} + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+ )} +
) } diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx deleted file mode 100644 index d218770d9..000000000 --- a/packages/desktop/src/components/dialog-model-unpaid.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Component, onCleanup, onMount, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" - -export const DialogModelUnpaid: Component = () => { - const local = useLocal() - const dialog = useDialog() - const providers = useProviders() - - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
Add more models from popular providers
-
- x?.id} - items={providers.popular} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
- -
-
-
-
- -
- ) -} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx deleted file mode 100644 index e8f9df055..000000000 --- a/packages/desktop/src/components/dialog-model.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Component, createMemo, Show } from "solid-js" -import { useLocal } from "@/context/local" -import { useDialog } from "@/context/dialog" -import { popularProviders } from "@/hooks/use-providers" -import { Button } from "@opencode-ai/ui/button" -import { Tag } from "@opencode-ai/ui/tag" -import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogManageModels } from "./dialog-manage-models" - -export const DialogModel: Component<{ provider?: string }> = (props) => { - const local = useLocal() - const dialog = useDialog() - - let closeButton!: HTMLButtonElement - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.provider ? m.provider.id === props.provider : true)), - ) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - - - `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.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, - }) - closeButton.click() - }} - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- -
-
- ) -} diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx new file mode 100644 index 000000000..0250963b0 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -0,0 +1,44 @@ +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useSession } from "@/context/session" +import { useDialog } from "@opencode-ai/ui/context/dialog" + +export function DialogSelectFile() { + const session = useSession() + const local = useLocal() + const dialog = useDialog() + return ( + + x} + onSelect={(path) => { + if (path) { + session.layout.openTab("file://" + path) + } + dialog.clear() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx new file mode 100644 index 000000000..1c9e9cc75 --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -0,0 +1,119 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogSelectModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ x?.id} + items={providers.popular} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx new file mode 100644 index 000000000..805db47fe --- /dev/null +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -0,0 +1,85 @@ +import { Component, createMemo, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogManageModels } from "./dialog-manage-models" + +export const DialogSelectModel: Component<{ provider?: string }> = (props) => { + const local = useLocal() + const dialog = useDialog() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) + + return ( + dialog.replace(() => )} + > + Connect provider + + } + > + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.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, + }) + closeButton.click() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 1c54184bd..292d5fccb 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,5 +1,5 @@ import { Component, Show } from "solid-js" -import { useDialog } from "@/context/dialog" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" @@ -13,66 +13,52 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Connect provider - - - - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
-
+ + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index faecd9520..296fe8b2f 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -15,9 +15,9 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useDialog } from "@/context/dialog" -import { DialogModel } from "@/components/dialog-model" -import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { @@ -616,7 +616,9 @@ export const PromptInput: Component = (props) => {
@@ -610,12 +611,6 @@ export default function Page() {
- - setStore("fileSelectOpen", open)} - onSelect={(path) => session.layout.openTab("file://" + path)} - /> -
["class"] classList?: ComponentProps<"div">["classList"] } -function DialogRoot(props: DialogProps) { - let trigger!: HTMLElement - const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"]) - - const resetTabIndex = () => { - trigger.tabIndex = 0 - } - - const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => { - const firstChild = e.currentTarget?.firstElementChild as HTMLElement - if (!firstChild) return - - firstChild.focus() - trigger.tabIndex = -1 - - firstChild.addEventListener("focusout", resetTabIndex) - onCleanup(() => { - firstChild.removeEventListener("focusout", resetTabIndex) - }) - } - - onMount(() => { - // @ts-ignore - document?.activeElement?.blur?.() - }) - +export function Dialog(props: DialogProps) { return ( - - - - {props.trigger} - - - - -
-
- - {local.children} - -
-
-
-
+
+
+ + +
+ + {props.title} + + + {props.action} + + + + +
+
+ + {props.description} + +
{props.children}
+
+
+
) } - -function DialogHeader(props: ComponentProps<"div">) { - return
-} - -function DialogBody(props: ComponentProps<"div">) { - return
-} - -function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) { - return -} - -function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) { - return -} - -function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) { - return -} - -export const Dialog = Object.assign(DialogRoot, { - Header: DialogHeader, - Title: DialogTitle, - Description: DialogDescription, - CloseButton: DialogCloseButton, - Body: DialogBody, -}) diff --git a/packages/desktop/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx similarity index 70% rename from packages/desktop/src/context/dialog.tsx rename to packages/ui/src/context/dialog.tsx index cc49764fe..af5da06f9 100644 --- a/packages/desktop/src/context/dialog.tsx +++ b/packages/ui/src/context/dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, onCleanup, Show, type JSX } from "solid-js" +import { For, Show, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" @@ -14,23 +14,6 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }[], }) - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape" && store.stack.length > 0) { - const current = store.stack.at(-1)! - current.onClose?.() - setStore("stack", store.stack.slice(0, -1)) - e.preventDefault() - e.stopPropagation() - } - } - - createEffect(() => { - document.addEventListener("keydown", handleKeyDown, true) - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown, true) - }) - }) - return { get stack() { return store.stack @@ -59,6 +42,8 @@ export const { use: useDialog, provider: DialogProvider } = createSimpleContext( }, }) +import { Dialog as Kobalte } from "@kobalte/core/dialog" + export function DialogRoot(props: { children?: JSX.Element }) { const dialog = useDialog() return ( @@ -69,7 +54,21 @@ export function DialogRoot(props: { children?: JSX.Element }) { {(item, index) => ( - {typeof item.element === "function" ? item.element() : item.element} + { + if (!open) { + item.onClose?.() + dialog.pop() + } + }} + > + + + {typeof item.element === "function" ? item.element() : item.element} + + )} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts index 3e0f5de74..499cb74d4 100644 --- a/packages/ui/src/context/index.ts +++ b/packages/ui/src/context/index.ts @@ -1,3 +1,4 @@ export * from "./helper" export * from "./data" export * from "./diff" +export * from "./dialog" From ad5614bbb91004855608fc98f3d0e75033d52ccf Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 20:33:18 -0600 Subject: [PATCH 63/79] wip(desktop): progress --- .../{dialog-connect.tsx => dialog-connect-provider.tsx} | 2 +- .../desktop/src/components/dialog-select-model-unpaid.tsx | 4 ++-- packages/desktop/src/components/dialog-select-provider.tsx | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename packages/desktop/src/components/{dialog-connect.tsx => dialog-connect-provider.tsx} (99%) diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx similarity index 99% rename from packages/desktop/src/components/dialog-connect.tsx rename to packages/desktop/src/components/dialog-connect-provider.tsx index f61221f72..4660e1398 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -20,7 +20,7 @@ import { Link } from "@/components/link" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectModel } from "./dialog-select-model" -export function DialogConnect(props: { provider: string }) { +export function DialogConnectProvider(props: { provider: string }) { const dialog = useDialog() const globalSync = useGlobalSync() const globalSDK = useGlobalSDK() diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx index 1c9e9cc75..7cdb24915 100644 --- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -9,7 +9,7 @@ import { List, ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" +import { DialogConnectProvider } from "./dialog-connect-provider" export const DialogSelectModelUnpaid: Component = () => { const local = useLocal() @@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.replace(() => ) }} > {(i) => ( diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 292d5fccb..8da10b1d5 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -6,7 +6,7 @@ import { List } from "@opencode-ai/ui/list" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" -import { DialogConnect } from "./dialog-connect" +import { DialogConnectProvider } from "./dialog-connect-provider" export const DialogSelectProvider: Component = () => { const dialog = useDialog() @@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => ) + dialog.replace(() => ) }} > {(i) => ( From ba16bfdf3d52da982dc984c77bbf2ec7768b07d6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:35:13 -0600 Subject: [PATCH 64/79] wip(desktop): progress --- .../src/components/dialog-manage-models.tsx | 17 ++++++---- .../src/components/dialog-select-model.tsx | 5 ++- packages/desktop/src/context/local.tsx | 20 ++++++------ packages/ui/src/components/list.tsx | 32 ++++++++++++------- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx index de1c3cb15..5765a8e1a 100644 --- a/packages/desktop/src/components/dialog-manage-models.tsx +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -27,18 +27,21 @@ export const DialogManageModels: Component = () => { }} onSelect={(x) => { if (!x) return - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id }) + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) }} > {(i) => (
{i.name} - { - local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) - }} - /> +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
)} diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx index 805db47fe..f0b2e6db9 100644 --- a/packages/desktop/src/components/dialog-select-model.tsx +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -13,11 +13,10 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { const local = useLocal() const dialog = useDialog() - let closeButton!: HTMLButtonElement const models = createMemo(() => local.model .list() - .filter((m) => m.visible) + .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id })) .filter((m) => (props.provider ? m.provider.id === props.provider : true)), ) @@ -58,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - closeButton.click() + dialog.clear() }} > {(i) => ( diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 0970178ea..56154c5ba 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -132,10 +132,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ Object.values(p.models).map((m) => ({ ...m, provider: p, - user: store.user.find((x) => x.modelID === m.id && x.providerID === p.id), })), ), ) + const latest = createMemo(() => pipe( available(), @@ -163,10 +163,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ...m, name: m.name.replace("(latest)", "").trim(), latest: m.name.includes("(latest)"), - visible: - m.user?.visibility !== "hide" && - (latest().find((x) => x.modelID === m.id && x.providerID === m.provider.id) || - store.user.find((x) => x.modelID === m.id && x.providerID === m.provider.id)?.visibility === "show"), })), ) @@ -241,7 +237,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (index >= 0) { setStore("user", index, { visibility }) } else { - setStore("user", (prev) => [...prev, { ...model, visibility }]) + setStore("user", store.user.length, { ...model, visibility }) } } @@ -260,11 +256,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } }) }, - show(model: ModelKey) { - updateVisibility(model, "show") - }, - hide(model: ModelKey) { - updateVisibility(model, "hide") + visible(model: ModelKey) { + const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID) + return ( + user?.visibility !== "hide" && + (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) || + user?.visibility === "show") + ) }, setVisibility(model: ModelKey, visible: boolean) { updateVisibility(model, visible ? "show" : "hide") diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2923956a9..7ec6e159d 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, For, type JSX, createSignal } from "solid-js" +import { createEffect, on, Show, For, type JSX, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" @@ -32,24 +32,34 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) mouseActive: false, }) - const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList(props) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) - const hasSearch = () => !!props.search createEffect(() => { if (props.filter !== undefined) { onInput(props.filter) - } else if (hasSearch()) { - onInput(internalFilter()) } }) - createEffect(() => { - filter() - scrollRef()?.scrollTo(0, 0) - reset() - }) + createEffect((prev) => { + if (!props.search) return + const current = internalFilter() + if (prev !== current) { + onInput(current) + } + return current + }, "") + + createEffect( + on( + filter, + () => { + scrollRef()?.scrollTo(0, 0) + }, + { defer: true }, + ), + ) createEffect(() => { if (!scrollRef()) return @@ -100,7 +110,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- +
From 654534ac716622aa3eb2d9c8b96db4a371252a46 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:41:20 -0600 Subject: [PATCH 65/79] fix: update sdk --- packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 31d5b8561..c466e78dc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1662,6 +1662,7 @@ export type Model = { headers: { [key: string]: string } + release_date: string } export type Provider = { From 79a4c6531371433b0e778aeacd4a5f9a991c77d3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 21:42:17 -0600 Subject: [PATCH 66/79] fix: test --- packages/opencode/test/provider/transform.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4e202a63c..eecf43783 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -144,6 +144,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result).toHaveLength(1) @@ -204,6 +205,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBe("Thinking...") @@ -250,6 +252,7 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { status: "active", options: {}, headers: {}, + release_date: "2023-04-01", }) expect(result[0].content).toEqual([ From 6a09861806fa6b2068f8251e598c14e2b756ab00 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 03:42:56 +0000 Subject: [PATCH 67/79] chore: format code --- packages/sdk/openapi.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 21928684a..71f1df312 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8681,9 +8681,24 @@ "additionalProperties": { "type": "string" } + }, + "release_date": { + "type": "string" } }, - "required": ["id", "providerID", "api", "name", "capabilities", "cost", "limit", "status", "options", "headers"] + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ] }, "Provider": { "type": "object", From 54569b55525de3c25c78468299261a9d94517450 Mon Sep 17 00:00:00 2001 From: Ravi Kumar <82090231+Raviguntakala@users.noreply.github.com> Date: Mon, 15 Dec 2025 09:35:06 +0530 Subject: [PATCH 68/79] fix(session): fix unshare command not clearing share state (#5523) --- packages/opencode/src/cli/cmd/run.ts | 8 ++++---- .../src/cli/cmd/tui/routes/session/index.tsx | 11 +++++++---- packages/opencode/src/session/index.ts | 16 +++------------- packages/opencode/src/share/share-next.ts | 2 +- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c57711b4c..23456c75e 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -277,8 +277,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } @@ -330,8 +330,8 @@ export const RunCommand = cmd({ } return { error } }) - if (!shareResult.error) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8)) + if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url) } } 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 1c1e4b65e..48f7db054 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -323,10 +323,13 @@ export function Session() { keybind: "session_unshare", disabled: !session()?.share?.url, category: "Session", - onSelect: (dialog) => { - sdk.client.session.unshare({ - sessionID: route.sessionID, - }) + onSelect: async (dialog) => { + await sdk.client.session + .unshare({ + sessionID: route.sessionID, + }) + .then(() => toast.show({ message: "Session unshared successfully", variant: "success" })) + .catch(() => toast.show({ message: "Failed to unshare session", variant: "error" })) dialog.clear() }, }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index bf3135284..b1a193904 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -234,22 +234,12 @@ export namespace Session { }) export const unshare = fn(Identifier.schema("session"), async (id) => { - const cfg = await Config.get() - if (cfg.enterprise?.url) { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - await update(id, (draft) => { - draft.share = undefined - }) - } - const share = await getShare(id) - if (!share) return - await Storage.remove(["share", id]) + // Use ShareNext to remove the share (same as share function uses ShareNext to create) + const { ShareNext } = await import("@/share/share-next") + await ShareNext.remove(id) await update(id, (draft) => { draft.share = undefined }) - const { Share } = await import("../share/share") - await Share.remove(id, share.secret) }) export async function update(id: string, editor: (session: Info) => void) { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index fea9c3bb9..37ecdf7ea 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -157,7 +157,7 @@ export namespace ShareNext { secret: share.secret, }), }) - await Storage.remove(["session_share", share.id]) + await Storage.remove(["session_share", sessionID]) } async function fullSync(sessionID: string) { From 543dbe71d28e28c409fdd4ce40717b9f5535d1b3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:36:46 -0600 Subject: [PATCH 69/79] ci: smart oc --- .github/workflows/opencode.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 4c75ad2e0..e6148acbd 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -32,3 +32,29 @@ jobs: OPENCODE_PERMISSION: '{"bash": "deny"}' with: model: opencode/claude-haiku-4-5 + + opencode-smart: + if: | + contains(github.event.comment.body, ' /soc') || + startsWith(github.event.comment.body, '/soc') || + contains(github.event.comment.body, ' /smart-opencode') || + startsWith(github.event.comment.body, '/smart-opencode') + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + id-token: write + contents: read + pull-requests: read + issues: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - uses: ./.github/actions/setup-bun + + - name: Run opencode + uses: sst/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_PERMISSION: '{"bash": "deny"}' + with: + model: opencode/claude-opus-4-5 From cf5c0129ac2663950b049d89367c35f4f13d3a2d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 15 Dec 2025 12:17:41 +0800 Subject: [PATCH 70/79] tauri: rename sidecar to opencode-cli --- packages/tauri/scripts/utils.ts | 2 +- packages/tauri/src-tauri/tauri.conf.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri/scripts/utils.ts b/packages/tauri/scripts/utils.ts index b2885d00a..3e74346c8 100644 --- a/packages/tauri/scripts/utils.ts +++ b/packages/tauri/scripts/utils.ts @@ -36,7 +36,7 @@ export function getCurrentSidecar(target = RUST_TARGET) { export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) { await $`mkdir -p src-tauri/sidecars` - const dest = `src-tauri/sidecars/opencode-${target}${process.platform === "win32" ? ".exe" : ""}` + const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}` await $`cp ${source} ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/tauri/src-tauri/tauri.conf.json b/packages/tauri/src-tauri/tauri.conf.json index 607b94134..6813a218b 100644 --- a/packages/tauri/src-tauri/tauri.conf.json +++ b/packages/tauri/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ "active": true, "targets": ["deb", "rpm", "dmg", "nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], - "externalBin": ["sidecars/opencode"], + "externalBin": ["sidecars/opencode-cli"], "createUpdaterArtifacts": true, "macOS": { "entitlements": "./entitlements.plist" From 220c564047946a96ac604a668926d38b99cfec88 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 15 Dec 2025 12:40:49 +0800 Subject: [PATCH 71/79] tauri: use correct sidecar name --- packages/tauri/src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index a275fab78..b06ccd06c 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -4,9 +4,9 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow}; #[cfg(target_os = "macos")] use tauri::TitleBarStyle; +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; @@ -66,7 +66,7 @@ fn find_and_kill_process_on_port(port: u16) -> Result<(), Box CommandChild { let (mut rx, child) = app .shell() - .sidecar("opencode") + .sidecar("opencode-cli") .unwrap() .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .env("OPENCODE_CLIENT", "desktop") From 9555d348de8d6fdc389f8f554dd28d3eb79e504d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:41:39 -0600 Subject: [PATCH 72/79] ci: switch model --- .github/workflows/opencode.yml | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index e6148acbd..37210191e 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -25,32 +25,6 @@ jobs: - uses: ./.github/actions/setup-bun - - name: Run opencode - uses: sst/opencode/github@latest - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - OPENCODE_PERMISSION: '{"bash": "deny"}' - with: - model: opencode/claude-haiku-4-5 - - opencode-smart: - if: | - contains(github.event.comment.body, ' /soc') || - startsWith(github.event.comment.body, '/soc') || - contains(github.event.comment.body, ' /smart-opencode') || - startsWith(github.event.comment.body, '/smart-opencode') - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - id-token: write - contents: read - pull-requests: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: ./.github/actions/setup-bun - - name: Run opencode uses: sst/opencode/github@latest env: From b021b26e77b4982e27b77a33d51535bfd9d0ff76 Mon Sep 17 00:00:00 2001 From: DS <78942835+Tarquinen@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:51:11 -0500 Subject: [PATCH 73/79] feat: restore experimental.chat.messages.transform and add experimental.chat.system.transform hooks (#5542) --- packages/opencode/src/session/llm.ts | 8 +++++++- packages/opencode/src/session/prompt.ts | 8 ++++++-- packages/plugin/src/index.ts | 6 ++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 97b8aae2b..565d037f4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,7 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" -import { mergeDeep, pipe } from "remeda" +import { clone, mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" import { Instance } from "@/project/instance" @@ -60,6 +60,12 @@ export namespace LLM { .join("\n"), ) + const original = clone(system) + await Plugin.trigger("experimental.chat.system.transform", {}, { system }) + if (system.length === 0) { + system.push(...original) + } + const params = await Plugin.trigger( "chat.params", { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9a36c5c62..e71162d0b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -20,7 +20,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" @@ -480,6 +480,10 @@ export namespace SessionPrompt { }) } + const sessionMessages = clone(msgs) + + await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + const result = await processor.process({ user: lastUser, agent, @@ -487,7 +491,7 @@ export namespace SessionPrompt { sessionID, system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())], messages: [ - ...MessageV2.toModelMessage(msgs), + ...MessageV2.toModelMessage(sessionMessages), ...(isLastStep ? [ { diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 57ca75d60..9dd4820b9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -185,6 +185,12 @@ export interface Hooks { }[] }, ) => Promise + "experimental.chat.system.transform"?: ( + input: {}, + output: { + system: string[] + }, + ) => Promise "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, From ae1bf92c815b32a2eb9808e813cb7f9a593f20dd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:58:26 -0600 Subject: [PATCH 74/79] Add dismiss button to Getting Started box (#5543) Co-authored-by: opencode-agent[bot] Co-authored-by: rekram1-node Co-authored-by: Aiden Cline --- .../src/cli/cmd/tui/routes/session/sidebar.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 b5208cd1c..d79200d5b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -9,6 +9,7 @@ import { Global } from "@/global" import { Installation } from "@/installation" import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" +import { useKV } from "../../context/kv" export function Sidebar(props: { sessionID: string }) { const sync = useSync() @@ -48,12 +49,13 @@ export function Sidebar(props: { sessionID: string }) { } }) - const keybind = useKeybind() const directory = useDirectory() + const kv = useKV() const hasProviders = createMemo(() => sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) + const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false)) return ( @@ -249,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) { - + - - Getting started - + + + Getting started + + kv.set("dismissed_getting_started", true)}> + ✕ + + OpenCode includes free models so you can start immediately. Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc From 509f7d961768e56b6b3862adf19779518b3e0d0f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 14 Dec 2025 22:59:30 -0600 Subject: [PATCH 75/79] ignore: fix debug var in last commit --- packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d79200d5b..b64a18ae2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -251,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) { - + Date: Mon, 15 Dec 2025 06:01:50 +0100 Subject: [PATCH 76/79] fix(edit): add per-file lock to prevent read-before-write race (#4388) --- packages/opencode/src/file/time.ts | 26 ++++++++++++++++++++++++++ packages/opencode/src/tool/edit.ts | 8 ++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 5cba5e820..770427abe 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -3,14 +3,20 @@ import { Log } from "../util/log" export namespace FileTime { const log = Log.create({ service: "file.time" }) + // Per-session read times plus per-file write locks. + // All tools that overwrite existing files should run their + // assert/read/write/update sequence inside withLock(filepath, ...) + // so concurrent writes to the same file are serialized. export const state = Instance.state(() => { const read: { [sessionID: string]: { [path: string]: Date | undefined } } = {} + const locks = new Map>() return { read, + locks, } }) @@ -25,6 +31,26 @@ export namespace FileTime { return state().read[sessionID]?.[file] } + export async function withLock(filepath: string, fn: () => Promise): Promise { + const current = state() + const currentLock = current.locks.get(filepath) ?? Promise.resolve() + let release: () => void = () => {} + const nextLock = new Promise((resolve) => { + release = resolve + }) + const chained = currentLock.then(() => nextLock) + current.locks.set(filepath, chained) + await currentLock + try { + return await fn() + } finally { + release() + if (current.locks.get(filepath) === chained) { + current.locks.delete(filepath) + } + } + } + export async function assert(sessionID: string, filepath: string) { const time = get(sessionID, filepath) if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 62814dbf9..fdf115ac4 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -76,7 +76,7 @@ export const EditTool = Tool.define("edit", { let diff = "" let contentOld = "" let contentNew = "" - await (async () => { + await FileTime.withLock(filePath, async () => { if (params.oldString === "") { contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) @@ -97,6 +97,7 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) + FileTime.read(ctx.sessionID, filePath) return } @@ -133,9 +134,8 @@ export const EditTool = Tool.define("edit", { diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), ) - })() - - FileTime.read(ctx.sessionID, filePath) + FileTime.read(ctx.sessionID, filePath) + }) let output = "" await LSP.touchFile(filePath, true) From 7c1124199ecba33750c9b536c907359f40ed91de Mon Sep 17 00:00:00 2001 From: Nalin Singh <38408670+nalin-singh@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:40:33 +0530 Subject: [PATCH 77/79] fix: input lip visibility for transparent themes (#5544) --- .../src/cli/cmd/tui/component/prompt/index.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) 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 784c8648e..eefe43d1f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -873,17 +873,24 @@ export function Prompt(props: PromptProps) { borderColor={highlight()} customBorderChars={{ ...EmptyBorder, - vertical: "╹", + vertical: theme.backgroundElement.a !== 0 ? "╹" : " ", }} > From 9eefcd1b41db29e5142bce800790f559f01f2877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Mon, 15 Dec 2025 06:56:47 +0100 Subject: [PATCH 78/79] Provider fix, anthropic Errorhandling if empty image file is read (#5521) --- packages/opencode/src/provider/transform.ts | 14 +++ .../opencode/test/provider/transform.test.ts | 103 ++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 728c26a27..9af5589e8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -171,6 +171,20 @@ export namespace ProviderTransform { const filtered = msg.content.map((part) => { if (part.type !== "file" && part.type !== "image") return part + // Check for empty base64 image data + if (part.type === "image") { + const imageStr = part.image.toString() + if (imageStr.startsWith("data:")) { + const match = imageStr.match(/^data:([^;]+);base64,(.*)$/) + if (match && (!match[2] || match[2].length === 0)) { + return { + type: "text" as const, + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + } + } + } + } + const mime = part.type === "image" ? part.image.toString().split(";")[0].replace("data:", "") : part.mediaType const filename = part.type === "file" ? part.filename : undefined const modality = mimeToModality(mime) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index eecf43783..b040f24f4 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -262,3 +262,106 @@ describe("ProviderTransform.message - DeepSeek reasoning content", () => { expect(result[0].providerOptions?.openaiCompatible?.reasoning_content).toBeUndefined() }) }) + +describe("ProviderTransform.message - empty image handling", () => { + const mockModel = { + id: "anthropic/claude-3-5-sonnet", + providerID: "anthropic", + api: { + id: "claude-3-5-sonnet-20241022", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + name: "Claude 3.5 Sonnet", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0.003, + output: 0.015, + cache: { read: 0.0003, write: 0.00375 }, + }, + limit: { + context: 200000, + output: 8192, + }, + status: "active", + options: {}, + headers: {}, + } as any + + test("should replace empty base64 image with error text", () => { + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: "data:image/png;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) + + test("should keep valid base64 images unchanged", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(2) + expect(result[0].content[0]).toEqual({ type: "text", text: "What is in this image?" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + }) + + test("should handle mixed valid and empty images", () => { + const validBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "Compare these images" }, + { type: "image", image: `data:image/png;base64,${validBase64}` }, + { type: "image", image: "data:image/jpeg;base64," }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, mockModel) + + expect(result).toHaveLength(1) + expect(result[0].content).toHaveLength(3) + expect(result[0].content[0]).toEqual({ type: "text", text: "Compare these images" }) + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validBase64}` }) + expect(result[0].content[2]).toEqual({ + type: "text", + text: "ERROR: Image file is empty or corrupted. Please provide a valid image.", + }) + }) +}) From ed6d74910443c809f96eaed38a7b3147f0c47c2b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 15 Dec 2025 12:05:05 +0000 Subject: [PATCH 79/79] ignore: update download stats 2025-12-15 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 1a1081d3a..9d60266d2 100644 --- a/STATS.md +++ b/STATS.md @@ -170,3 +170,4 @@ | 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) | | 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) | | 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) | +| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |