From 6288a032fd5c0ea7085e2f31071212147fed1cfe Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 14:48:52 -0600 Subject: [PATCH 01/70] bump bun to 1.3.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b866c9bdf08..65c8b5a81fa 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", From 03c6c3f4cbb176b8a24cdbac391eaab775112193 Mon Sep 17 00:00:00 2001 From: igordertigor Date: Wed, 10 Dec 2025 21:59:12 +0100 Subject: [PATCH 02/70] docs: document accept always behavior (#5340) Co-authored-by: Ingo Fruend --- packages/web/src/content/docs/permissions.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index dd359ee890b..1aea3ef740d 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -132,6 +132,18 @@ The wildcard uses simple regex globbing patterns. --- +#### Scope of the `"ask"` option + +When the agent asks for permission to run a particular bash command, it will +request feedback with the three options "accept", "accept always" and "deny". +The "accept always" answer applies for the rest of the current session. + +In addition, command permissions are applied to the first two elements of a command. So, an "accept always" response for a command like `git log` would whitelist `git log *` but not `git commit ...`. + +When an agent asks for permission to run a command in a pipeline, we use tree sitter to parse each command in the pipeline. The "accept always" permission thus applies separately to each command in the pipeline. + +--- + ### webfetch Use the `permission.webfetch` key to control whether the LLM can fetch web pages. From 936a6be5d62e401d292e84dab730fd47eec8ef0d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 15:59:12 -0500 Subject: [PATCH 03/70] stuff adam needs --- packages/opencode/src/server/server.ts | 14 ++++++++++++-- packages/opencode/src/session/index.ts | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6af1b490329..677c6cf1307 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -10,7 +10,7 @@ import { proxy } from "hono/proxy" import { Session } from "../session" import z from "zod" import { Provider } from "../provider/provider" -import { mapValues } from "remeda" +import { filter, mapValues, sortBy, pipe } from "remeda" import { NamedError } from "@opencode-ai/util/error" import { ModelsDev } from "../provider/models" import { Ripgrep } from "../file/ripgrep" @@ -483,6 +483,7 @@ export namespace Server { schema: resolver( z .object({ + home: z.string(), state: z.string(), config: z.string(), worktree: z.string(), @@ -499,6 +500,7 @@ export namespace Server { }), async (c) => { return c.json({ + home: Global.Path.home, state: Global.Path.state, config: Global.Path.config, worktree: Instance.worktree, @@ -549,7 +551,11 @@ export namespace Server { }), async (c) => { const sessions = await Array.fromAsync(Session.list()) - sessions.sort((a, b) => b.time.updated - a.time.updated) + pipe( + await Array.fromAsync(Session.list()), + filter((s) => !s.time.archived), + sortBy((s) => s.time.updated), + ) return c.json(sessions) }, ) @@ -755,6 +761,9 @@ export namespace Server { "json", z.object({ title: z.string().optional(), + time: z.object({ + archived: z.number().optional(), + }), }), ), async (c) => { @@ -765,6 +774,7 @@ export namespace Server { if (updates.title !== undefined) { session.title = updates.title } + if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived }) return c.json(updatedSession) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index a3369eb5457..484a1a8a7f5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -60,6 +60,7 @@ export namespace Session { created: z.number(), updated: z.number(), compacting: z.number().optional(), + archived: z.number().optional(), }), revert: z .object({ From a1175bddcdc5d6bbf0589f6b2af0066d8292a066 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 15:59:33 -0500 Subject: [PATCH 04/70] gen types --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3df3f62b7a7..36811308596 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -812,6 +812,9 @@ export class Session extends HeyApiClient { sessionID: string directory?: string title?: string + time?: { + archived?: number + } }, options?: Options, ) { @@ -823,6 +826,7 @@ export class Session extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "body", key: "title" }, + { in: "body", key: "time" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d1be315020a..716bae9f830 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -575,6 +575,7 @@ export type Session = { created: number updated: number compacting?: number + archived?: number } revert?: { messageID: string @@ -1391,6 +1392,7 @@ export type ToolListItem = { export type ToolList = Array export type Path = { + home: string state: string config: string worktree: string @@ -2249,6 +2251,9 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] export type SessionUpdateData = { body?: { title?: string + time: { + archived?: number + } } path: { sessionID: string From 0ab3b882507c44522b574a4cf5e4199ee58f93d1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 10 Dec 2025 21:01:06 +0000 Subject: [PATCH 05/70] chore: format code --- packages/sdk/openapi.json | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 962e4108f52..20ea2be5da3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1156,8 +1156,17 @@ "properties": { "title": { "type": "string" + }, + "time": { + "type": "object", + "properties": { + "archived": { + "type": "number" + } + } } - } + }, + "required": ["time"] } } } @@ -6382,6 +6391,9 @@ }, "compacting": { "type": "number" + }, + "archived": { + "type": "number" } }, "required": ["created", "updated"] @@ -8095,6 +8107,9 @@ "Path": { "type": "object", "properties": { + "home": { + "type": "string" + }, "state": { "type": "string" }, @@ -8108,7 +8123,7 @@ "type": "string" } }, - "required": ["state", "config", "worktree", "directory"] + "required": ["home", "state", "config", "worktree", "directory"] }, "VcsInfo": { "type": "object", From 59fb3ae606764e8bd3dc8f8d9fc40b952aee8257 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 15:07:32 -0600 Subject: [PATCH 06/70] ignore: add bash tests --- packages/opencode/test/tool/bash.test.ts | 417 +++++++++++++++++++++-- 1 file changed, 398 insertions(+), 19 deletions(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 0116f47cffb..9ef7dfb9d8f 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -3,11 +3,12 @@ import path from "path" import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { Permission } from "../../src/permission" +import { tmpdir } from "../fixture/fixture" const ctx = { sessionID: "test", messageID: "", - toolCallID: "", + callID: "", agent: "build", abort: AbortSignal.any([]), metadata: () => {}, @@ -33,23 +34,401 @@ describe("tool.bash", () => { }, }) }) +}) + +describe("tool.bash permissions", () => { + test("allows command matching allow pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "echo *": "allow", + "*": "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + expect(result.metadata.output).toContain("hello") + }, + }) + }) - // TODO: better test - // test("cd ../ should ask for permission for external directory", async () => { - // await Instance.provide({ - // directory: projectRoot, - // fn: async () => { - // bash.execute( - // { - // command: "cd ../", - // description: "Try to cd to parent directory", - // }, - // ctx, - // ) - // // Give time for permission to be asked - // await new Promise((resolve) => setTimeout(resolve, 1000)) - // expect(Permission.pending()[ctx.sessionID]).toBeDefined() - // }, - // }) - // }) + test("denies command matching deny pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "curl *": "deny", + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "curl https://example.com", + description: "Fetch URL", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) + + test("denies all commands with wildcard deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "*": "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "ls", + description: "List files", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) + + test("more specific pattern overrides general pattern", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "*": "deny", + "ls *": "allow", + "pwd*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // ls should be allowed + const result = await bash.execute( + { + command: "ls -la", + description: "List files", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + + // pwd should be allowed + const pwd = await bash.execute( + { + command: "pwd", + description: "Print working directory", + }, + ctx, + ) + expect(pwd.metadata.exit).toBe(0) + + // cat should be denied + await expect( + bash.execute( + { + command: "cat /etc/passwd", + description: "Read file", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) + + test("denies dangerous subcommands while allowing safe ones", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "find *": "allow", + "find * -delete*": "deny", + "find * -exec*": "deny", + "*": "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // Basic find should work + const result = await bash.execute( + { + command: "find . -name '*.ts'", + description: "Find typescript files", + }, + ctx, + ) + expect(result.metadata.exit).toBe(0) + + // find -delete should be denied + await expect( + bash.execute( + { + command: "find . -name '*.tmp' -delete", + description: "Delete temp files", + }, + ctx, + ), + ).rejects.toThrow("restricted") + + // find -exec should be denied + await expect( + bash.execute( + { + command: "find . -name '*.ts' -exec cat {} \\;", + description: "Find and cat files", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) + + test("allows git read commands while denying writes", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "git status*": "allow", + "git log*": "allow", + "git diff*": "allow", + "git branch": "allow", + "git commit *": "deny", + "git push *": "deny", + "*": "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // git status should work + const status = await bash.execute( + { + command: "git status", + description: "Git status", + }, + ctx, + ) + expect(status.metadata.exit).toBe(0) + + // git log should work + const log = await bash.execute( + { + command: "git log --oneline -5", + description: "Git log", + }, + ctx, + ) + expect(log.metadata.exit).toBe(0) + + // git commit should be denied + await expect( + bash.execute( + { + command: "git commit -m 'test'", + description: "Git commit", + }, + ctx, + ), + ).rejects.toThrow("restricted") + + // git push should be denied + await expect( + bash.execute( + { + command: "git push origin main", + description: "Git push", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) + + test("denies external directory access when permission is deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // Should deny cd to parent directory (cd is checked for external paths) + await expect( + bash.execute( + { + command: "cd ../", + description: "Change to parent directory", + }, + ctx, + ), + ).rejects.toThrow() + }, + }) + }) + + test("denies workdir outside project when external_directory is deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + external_directory: "deny", + bash: { + "*": "allow", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + await expect( + bash.execute( + { + command: "ls", + workdir: "/tmp", + description: "List /tmp", + }, + ctx, + ), + ).rejects.toThrow() + }, + }) + }) + + test("handles multiple commands in sequence", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + permission: { + bash: { + "echo *": "allow", + "curl *": "deny", + "*": "deny", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + // echo && echo should work + const result = await bash.execute( + { + command: "echo foo && echo bar", + description: "Echo twice", + }, + ctx, + ) + expect(result.metadata.output).toContain("foo") + expect(result.metadata.output).toContain("bar") + + // echo && curl should fail (curl is denied) + await expect( + bash.execute( + { + command: "echo hi && curl https://example.com", + description: "Echo then curl", + }, + ctx, + ), + ).rejects.toThrow("restricted") + }, + }) + }) }) From 9ad828dcd0e2bbcd02eee2700856c20ed118e174 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:13:04 -0500 Subject: [PATCH 07/70] tui: use random free port and enable icon discovery by default - Tauri app now automatically finds an available port instead of defaulting to 4096 - Icon discovery feature is now enabled by default in the Tauri app - Prevents port conflicts when multiple OpenCode instances are running --- packages/opencode/src/flag/flag.ts | 2 ++ packages/opencode/src/project/project.ts | 2 +- packages/tauri/src-tauri/src/lib.rs | 11 +++++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index f0044607cbd..36cebf6aacb 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -14,6 +14,8 @@ export namespace Flag { // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") + export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = + OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER") export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") export const OPENCODE_ENABLE_EXA = diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 62459cc288e..80c71260573 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -107,7 +107,7 @@ export namespace Project { await migrateFromGlobal(id, worktree) } } - if (Flag.OPENCODE_EXPERIMENTAL) discover(existing) + if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) const result: Info = { ...existing, worktree, diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index afb34094f66..d380e35768d 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - net::SocketAddr, + net::{SocketAddr, TcpListener}, process::Command, sync::{Arc, Mutex}, time::{Duration, Instant}, @@ -18,7 +18,13 @@ fn get_sidecar_port() -> u16 { .map(|s| s.to_string()) .or_else(|| std::env::var("OPENCODE_PORT").ok()) .and_then(|port_str| port_str.parse().ok()) - .unwrap_or(4096) + .unwrap_or_else(|| { + TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind to find free port") + .local_addr() + .expect("Failed to get local address") + .port() + }) } fn find_and_kill_process_on_port(port: u16) -> Result<(), Box> { @@ -60,6 +66,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) -> CommandChild { .shell() .sidecar("opencode") .unwrap() + .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .args(["serve", &format!("--port={port}")]) .spawn() .expect("Failed to spawn opencode"); From 8d3eac2347c525039e96a540c29d6bb9cc26cc8f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:14:32 -0500 Subject: [PATCH 08/70] fix type --- packages/desktop/src/context/global-sync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 890801611bf..58fc8c9cd6f 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -97,7 +97,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple setGlobalStore("children", directory, { project: "", config: {}, - path: { state: "", config: "", worktree: "", directory: "" }, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], provider: [], From 67a95c3cc8de5dc8e71f8af8efbaca4e7efdb4d6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:05:27 -0600 Subject: [PATCH 09/70] wip(desktop): progress --- packages/desktop/src/context/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index b7d1fabb58f..05a47c4eb7c 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -66,7 +66,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open(directory: string) { if (store.projects.find((x) => x.worktree === directory)) return loadProjectSessions(directory) - setStore("projects", (x) => [...x, { worktree: directory, expanded: true }]) + setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) }, close(directory: string) { setStore("projects", (x) => x.filter((x) => x.worktree !== directory)) From a4ec619c74318c499c61c3198a3f82e9262cc7e5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:52:05 -0600 Subject: [PATCH 10/70] wip(desktop): progress --- packages/desktop/src/pages/layout.tsx | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 3ff3abb0ee8..0c8fdf6d73b 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -44,6 +44,10 @@ export default function Layout(props: ParentProps) { const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const hasProviders = createMemo(() => { + const [projectStore] = globalSync.child(currentDirectory()) + return projectStore.provider.filter((p) => p.id !== "opencode").length > 0 + }) function navigateToProject(directory: string | undefined) { if (!directory) return @@ -82,6 +86,8 @@ export default function Layout(props: ParentProps) { } } + async function connectProvider() {} + createEffect(() => { if (!params.dir || !params.id) return const directory = base64Decode(params.dir) @@ -465,6 +471,40 @@ export default function Layout(props: ParentProps) {
+ + +
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
+ + + +
+
+ + + + + +
- )} - + + 0} + fallback={ +
+
+ {props.emptyMessage ?? "No search results"} for{" "} + "{filter()}"
- )} - -
-
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item) => ( + + )} + +
+
+ )} +
+
+ +
) } diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index bc6bb6f6daa..d0a414fee7e 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -57,6 +57,7 @@ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; + --radius-xl: 0.625rem; --shadow-xs: var(--shadow-xs); --shadow-md: var(--shadow-md); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 4450358f839..338e045ef05 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -43,6 +43,7 @@ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; + --radius-xl: 0.625rem; --shadow-xs: 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); From ada40decd14fc18901486382a10b1ec1d0d21f7e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:55:44 -0600 Subject: [PATCH 12/70] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 20 +- packages/ui/index.html | 14 - packages/ui/src/components/icon.tsx | 187 ++--------- packages/ui/src/components/select-dialog.css | 17 +- packages/ui/src/components/select-dialog.tsx | 2 + packages/ui/src/components/tag.css | 37 +++ packages/ui/src/components/tag.tsx | 22 ++ packages/ui/src/demo.tsx | 291 ------------------ packages/ui/src/index.css | 40 --- packages/ui/src/index.tsx | 22 -- packages/ui/src/styles/index.css | 1 + packages/ui/src/styles/theme.css | 1 + 12 files changed, 101 insertions(+), 553 deletions(-) delete mode 100644 packages/ui/index.html create mode 100644 packages/ui/src/components/tag.css create mode 100644 packages/ui/src/components/tag.tsx delete mode 100644 packages/ui/src/demo.tsx delete mode 100644 packages/ui/src/index.css delete mode 100644 packages/ui/src/index.tsx diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 824d3da1217..fbb643e58bd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -3,7 +3,6 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat import { createStore } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { DateTime } from "luxon" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" import { useSDK } from "@/context/sdk" import { useNavigate } from "@solidjs/router" @@ -14,10 +13,9 @@ 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 { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Select } from "@opencode-ai/ui/select" +import { Tag } from "@opencode-ai/ui/tag" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { type IconName } from "@opencode-ai/ui/icons/provider" interface PromptInputProps { class?: string @@ -486,20 +484,10 @@ export const PromptInput: Component = (props) => { } > {(i) => ( -
-
- {/* */} -
- {i.name} - - - {DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")} - - -
-
+
+ {i.name} -
Free
+ Free
)} diff --git a/packages/ui/index.html b/packages/ui/index.html deleted file mode 100644 index 7697a5f962a..00000000000 --- a/packages/ui/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - OpenCode UI - - - -
- - - diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 8c83b41ce0e..97f2e8eabf6 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -1,171 +1,44 @@ import { splitProps, type ComponentProps } from "solid-js" -// prettier-ignore const icons = { - close: '', - menu: ' ', - "chevron-right": '', - "chevron-left": '', - "chevron-down": '', - "chevron-up": '', - "chevron-down-square": '', - "chevron-up-square": '', - "chevron-right-square": '', - "chevron-left-square": '', - settings: '', - globe: '', - github: '', - hammer: '', - "avatar-square": '', - slash: '', - robot: '', - cloud: '', - "file-text": '', - file: '', - "file-checkmark": '', - "file-code": '', - "file-important": '', - "file-minus": '', - "file-plus": '', - files: '', - "file-zip": '', - jpg: '', - pdf: '', - png: '', - gif: '', - archive: '', - sun: '', - moon: '', - monitor: '', - command: '', - link: '', - share: '', - branch: '', - logout: '', - login: '', - keys: '', - key: '', - info: '', - warning: '', - checkmark: '', - "checkmark-square": '', - plus: '', - minus: '', - undo: '', - merge: '', - redo: '', - refresh: '', - rotate: '', - "arrow-left": '', - "arrow-down": '', - "arrow-right": '', - "arrow-up": '', - enter: '', - trash: '', - package: '', - box: '', - lock: '', - unlocked: '', - activity: '', - asterisk: '', - bell: '', - "bell-off": '', - bolt: '', - bookmark: '', - brain: '', - browser: '', - "browser-cursor": '', - bug: '', - "carat-down": '', - "carat-left": '', - "carat-right": '', - "carat-up": '', - cards: '', - chart: '', - "check-circle": '', - checklist: '', - "checklist-cards": '', - lab: '', - circle: '', - "circle-dotted": '', - clipboard: '', - clock: '', - "close-circle": '', - terminal: '', - code: '', - components: '', - copy: '', - cpu: '', - dashboard: '', - transfer: '', - devices: '', - diamond: '', - dice: '', - discord: '', - dots: '', - expand: '', - droplet: '', - "chevron-double-down": '', - "chevron-double-left": '', - "chevron-double-right": '', - "chevron-double-up": '', - "speech-bubble": '', - message: '', - annotation: '', - square: '', - "pull-request": '', - pencil: '', - sparkles: '', - photo: '', - columns: '', - "open-pane": '', - "close-pane": '', - "file-search": '', - "folder-search": '', - search: '', - "web-search": '', - loading: '', - mic: '', -} as const - -const newIcons = { - "circle-x": ``, - "magnifying-glass": ``, - "plus-small": ``, - "chevron-down": ``, - "chevron-right": ``, + "align-right": ``, "arrow-up": ``, + "bubble-5": ``, + "bullet-list": ``, "check-small": ``, + "chevron-down": ``, + "chevron-right": ``, + "chevron-grabber-vertical": ``, + "circle-x": ``, + close: ``, + checklist: ``, + console: ``, + expand: ``, + collapse: ``, + "code-lines": ``, + "circle-ban-sign": ``, "edit-small-2": ``, + enter: ``, folder: ``, + "magnifying-glass": ``, + "plus-small": ``, "pencil-line": ``, - "chevron-grabber-vertical": ``, mcp: ``, glasses: ``, - "bullet-list": ``, "magnifying-glass-menu": ``, "window-cursor": ``, task: ``, - checklist: ``, - console: ``, - "code-lines": ``, - "square-arrow-top-right": ``, - "circle-ban-sign": ``, stop: ``, - enter: ``, "layout-left": ``, "layout-left-partial": ``, "layout-left-full": ``, "layout-right": ``, "layout-right-partial": ``, "layout-right-full": ``, + "square-arrow-top-right": ``, "speech-bubble": ``, - "align-right": ``, - expand: ``, - collapse: ``, "folder-add-left": ``, "settings-gear": ``, - "bubble-5": ``, github: ``, discord: ``, "layout-bottom": ``, @@ -175,32 +48,12 @@ const newIcons = { } export interface IconProps extends ComponentProps<"svg"> { - name: keyof typeof icons | keyof typeof newIcons + name: keyof typeof icons size?: "small" | "normal" | "large" } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - - if (local.name in newIcons) { - return ( -
- -
- ) - } - return (
+ {split.children} + + ) +} diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx deleted file mode 100644 index 6081f089493..00000000000 --- a/packages/ui/src/demo.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import type { Component } from "solid-js" -import { createSignal } from "solid-js" -import "./index.css" -import { Button } from "./components/button" -import { Select } from "./components/select" -import { Font } from "./components/font" -import { Accordion } from "./components/accordion" -import { Tabs } from "./components/tabs" -import { Tooltip } from "./components/tooltip" -import { Input } from "./components/input" -import { Checkbox } from "./components/checkbox" -import { Icon } from "./components/icon" -import { IconButton } from "./components/icon-button" -import { Dialog } from "./components/dialog" -import { SelectDialog } from "./components/select-dialog" -import { Collapsible } from "./components/collapsible" - -const Demo: Component = () => { - const [dialogOpen, setDialogOpen] = createSignal(false) - const [selectDialogOpen, setSelectDialogOpen] = createSignal(false) - const [inputValue, setInputValue] = createSignal("") - const [checked, setChecked] = createSignal(false) - const [termsAccepted, setTermsAccepted] = createSignal(false) - - const Content = (props: { dark?: boolean }) => ( -
-

Buttons

-
- - - - - - - - -
-

Select

-
- - setInputValue(e.currentTarget.value)} - /> - - -
-

Checkbox

-
- - - - - - - - -
-

Icons

-
- - - - - - - - -
-

Icon Buttons

-
- console.log("Close clicked")} /> - console.log("Check clicked")} /> - console.log("Search clicked")} disabled /> -
-

Dialog

-
- - - Example Dialog - This is an example dialog with a title and description. -
- - -
-
-
-

Select Dialog

-
- - x} - onSelect={(option) => { - console.log("Selected:", option) - setSelectDialogOpen(false) - }} - placeholder="Search options..." - > - {(item) =>
{item}
} -
-
-

Collapsible

-
- - - - - -
-

This is collapsible content that can be toggled open and closed.

-

It animates smoothly using CSS animations.

-
-
-
-
-

Accordion

-
- - - - What is Kobalte? - - -
-

Kobalte is a UI toolkit for building accessible web apps and design systems with SolidJS.

-
-
-
- - - Is it accessible? - - -
-

Yes. It adheres to the WAI-ARIA design patterns.

-
-
-
- - - Can it be animated? - - -
-

Yes! You can animate the content height using CSS animations.

-
-
-
-
-
-
- ) - - return ( - <> - -
- - -
- - ) -} - -export default Demo diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css deleted file mode 100644 index 27bcac4dac2..00000000000 --- a/packages/ui/src/index.css +++ /dev/null @@ -1,40 +0,0 @@ -@import "./styles/index.css"; - -:root { - body { - margin: 0; - background-color: var(--background-base); - color: var(--text-base); - } - main { - display: flex; - flex-direction: row; - overflow-x: hidden; - } - main > div { - flex: 1; - padding: 2rem; - min-width: 0; - overflow-x: hidden; - display: flex; - flex-direction: column; - gap: 2rem; - } - h3 { - font-size: 1.25rem; - font-weight: 600; - margin: 0 0 1rem 0; - margin-bottom: -1rem; - } - section { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - align-items: flex-start; - } -} - -.dark { - background-color: var(--background-base); - color: var(--text-base); -} diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx deleted file mode 100644 index fa76ba9af08..00000000000 --- a/packages/ui/src/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/* @refresh reload */ -import { render } from "solid-js/web" -import { MetaProvider } from "@solidjs/meta" - -import Demo from "./demo" - -const root = document.getElementById("root") - -if (import.meta.env.DEV && !(root instanceof HTMLElement)) { - throw new Error( - "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", - ) -} - -render( - () => ( - - - - ), - root!, -) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ab45a3a25f5..074859f352f 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -36,6 +36,7 @@ @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); +@import "../components/tag.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 338e045ef05..01ccc3fccdf 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -40,6 +40,7 @@ --container-6xl: 72rem; --container-7xl: 80rem; + --radius-xs: 0.125rem; --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; From e694d4d8806857fa5035c2953027ffee03e843dc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:23:07 -0600 Subject: [PATCH 13/70] wip(desktop): progress --- bun.lock | 4 ++-- package.json | 2 +- packages/desktop/src/pages/layout.tsx | 5 +++++ packages/ui/src/components/dialog.css | 3 ++- packages/ui/src/components/select-dialog.css | 9 ++++++++- packages/ui/src/components/select-dialog.tsx | 2 +- 6 files changed, 19 insertions(+), 6 deletions(-) diff --git a/bun.lock b/bun.lock index 1652adb3ee6..bb83e7682dd 100644 --- a/bun.lock +++ b/bun.lock @@ -462,7 +462,7 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.6.0-beta.10", + "@pierre/precision-diffs": "0.6.1", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", @@ -1277,7 +1277,7 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], - "@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.0-beta.10", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-2rdd1Q1xJbB0Z4oUbm0Ybrr2gLFEdvNetZLadJboZSFL7Q4gFujdQZfXfV3vB9X+esjt++v0nzb3mioW25BOTA=="], + "@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.1", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], diff --git a/package.json b/package.json index 65c8b5a81fa..4579a06f355 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/precision-diffs": "0.6.0-beta.10", + "@pierre/precision-diffs": "0.6.1", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", "ai": "5.0.97", diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 0c8fdf6d73b..4a17d01bd65 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -94,6 +94,11 @@ export default function Layout(props: ParentProps) { setStore("lastSession", directory, params.id) }) + createEffect(() => { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + }) + function getDraggableId(event: unknown): string | undefined { if (typeof event !== "object" || event === null) return undefined if (!("draggable" in event)) return undefined diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 267c891f392..1c7cd4f4115 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -16,6 +16,7 @@ [data-component="dialog"] { position: fixed; inset: 0; + margin-left: var(--dialog-left-margin); z-index: 50; display: flex; align-items: center; @@ -24,7 +25,7 @@ [data-slot="dialog-container"] { position: relative; z-index: 50; - width: min(calc(100vw - 16px), 624px); + width: min(calc(100vw - 16px), 480px); height: min(calc(100vh - 16px), 512px); display: flex; flex-direction: column; diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index 696f68bf97c..83085e0829f 100644 --- a/packages/ui/src/components/select-dialog.css +++ b/packages/ui/src/components/select-dialog.css @@ -121,12 +121,19 @@ line-height: var(--line-height-large); /* 142.857% */ letter-spacing: var(--letter-spacing-normal); + [data-slot="select-dialog-item-selected-icon"] { + display: none; + color: var(--icon-strong-base); + } + &[data-active="true"] { border-radius: var(--radius-md); background: var(--surface-raised-base-hover); } &[data-selected="true"] { - background: var(--surface-raised-base-hover); + [data-slot="select-dialog-item-selected-icon"] { + display: block; + } } } } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 381c5f6fcc4..90c269eea31 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -153,7 +153,7 @@ export function SelectDialog(props: SelectDialogProps) { }} > {others.children(item)} - + )} From f20d6e855556693e33cddd51c837263c8846694d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:27:30 -0600 Subject: [PATCH 14/70] wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 3 +++ packages/desktop/src/context/local.tsx | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index fbb643e58bd..bbd638e4461 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -489,6 +489,9 @@ export const PromptInput: Component = (props) => { Free + + Latest +
)} diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 8223a36b9cf..58a65b0de8c 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -25,6 +25,7 @@ export type View = LocalFile["view"] export type LocalModel = Omit & { provider: Provider + latest?: boolean } export type ModelKey = { providerID: string; modelID: string } @@ -114,7 +115,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const list = createMemo(() => - sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + sync.data.provider.flatMap((p) => + Object.values(p.models).map( + (m) => + ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + provider: p, + latest: m.name.includes("(latest)"), + }) as LocalModel, + ), + ), ) const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) From 804ad5897f17cd5f002fbd0c124d5301205efcfb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:46:10 -0600 Subject: [PATCH 15/70] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 3 ++- packages/ui/src/components/select-dialog.css | 4 ++-- packages/ui/src/components/select-dialog.tsx | 22 ++++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index bbd638e4461..8579647daf0 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -461,7 +461,8 @@ export const PromptInput: Component = (props) => { items={local.model.list()} current={local.model.current()} filterKeys={["provider.name", "name", "id"]} - groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] if (a.category === "Recent" && b.category !== "Recent") return -1 diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index 83085e0829f..206eade0d82 100644 --- a/packages/ui/src/components/select-dialog.css +++ b/packages/ui/src/components/select-dialog.css @@ -3,7 +3,7 @@ display: flex; flex-direction: column; overflow: hidden; - gap: 12px; + gap: 20px; padding: 0 10px; } @@ -38,7 +38,7 @@ [data-component="select-dialog"] { display: flex; flex-direction: column; - gap: 12px; + gap: 20px; [data-slot="select-dialog-empty-state"] { display: flex; diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 90c269eea31..695791aad4d 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, For, type JSX, splitProps } from "solid-js" +import { createEffect, Show, For, type JSX, splitProps, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Dialog, DialogProps } from "./dialog" @@ -21,7 +21,7 @@ export function SelectDialog(props: SelectDialogProps) { const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) let closeButton!: HTMLButtonElement let inputRef: HTMLInputElement | undefined - let scrollRef: HTMLDivElement | undefined + let [scrollRef, setScrollRef] = createSignal(undefined) const [store, setStore] = createStore({ mouseActive: false, }) @@ -38,18 +38,28 @@ export function SelectDialog(props: SelectDialogProps) { createEffect(() => { filter() - scrollRef?.scrollTo(0, 0) + scrollRef()?.scrollTo(0, 0) reset() }) + createEffect(() => { + if (!scrollRef()) return + if (!others.current) return + const key = others.key(others.current) + requestAnimationFrame(() => { + const element = scrollRef()!.querySelector(`[data-key="${key}"]`) + element?.scrollIntoView({ block: "center" }) + }) + }) + createEffect(() => { const all = flat() if (store.mouseActive || all.length === 0) return if (active() === others.key(all[0])) { - scrollRef?.scrollTo(0, 0) + scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) @@ -120,7 +130,7 @@ export function SelectDialog(props: SelectDialogProps) { />
- + 0} fallback={ From 91d743ef9a5c346fe17bb857db68dca92a6e9ba1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:48:08 -0600 Subject: [PATCH 16/70] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 5 ++ packages/desktop/src/context/global-sync.tsx | 73 ++++++------------- packages/desktop/src/context/layout.tsx | 67 +++++++++++++---- packages/desktop/src/pages/layout.tsx | 38 ++++++++++ packages/tauri/src-tauri/Cargo.lock | 34 ++++----- packages/tauri/src-tauri/Cargo.toml | 2 +- packages/ui/src/components/avatar.tsx | 7 +- packages/ui/src/components/button.css | 18 +++-- packages/ui/src/components/select-dialog.css | 1 - packages/ui/src/components/select-dialog.tsx | 6 +- 10 files changed, 156 insertions(+), 95 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 8579647daf0..97d27ee1e4a 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -483,6 +483,11 @@ export const PromptInput: Component = (props) => { } + actions={ + + } > {(i) => (
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 58fc8c9cd6f..3e2b6bf7d8b 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -18,41 +18,9 @@ import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" -const PASTEL_COLORS = [ - "#FCEAFD", // pastel pink - "#FFDFBA", // pastel peach - "#FFFFBA", // pastel yellow - "#BAFFC9", // pastel green - "#EAF6FD", // pastel blue - "#EFEAFD", // pastel lavender - "#FEC8D8", // pastel rose - "#D4F0F0", // pastel cyan - "#FDF0EA", // pastel coral - "#C1E1C1", // pastel mint -] - -function pickAvailableColor(usedColors: Set) { - const available = PASTEL_COLORS.filter((c) => !usedColors.has(c)) - if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] - return available[Math.floor(Math.random() * available.length)] -} - -async function ensureProjectColor( - project: Project, - sdk: ReturnType, - usedColors: Set, -): Promise { - if (project.icon?.color) return project - const color = pickAvailableColor(usedColors) - usedColors.add(color) - const updated = { ...project, icon: { ...project.icon, color } } - sdk.client.project.update({ projectID: project.id, icon: { color } }) - return updated -} - type State = { ready: boolean - provider: Provider[] + // provider: Provider[] agent: Agent[] project: string config: Config @@ -84,10 +52,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple const [globalStore, setGlobalStore] = createStore<{ ready: boolean projects: Project[] + providers: Provider[] children: Record }>({ ready: false, projects: [], + providers: [], children: {}, }) @@ -100,7 +70,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], - provider: [], + // provider: [], session: [], session_status: {}, session_diff: {}, @@ -124,20 +94,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple if (directory === "global") { switch (event.type) { case "project.updated": { - const usedColors = new Set(globalStore.projects.map((p) => p.icon?.color).filter(Boolean) as string[]) - ensureProjectColor(event.properties, sdk, usedColors).then((project) => { - const result = Binary.search(globalStore.projects, project.id, (s) => s.id) - if (result.found) { - setGlobalStore("projects", result.index, reconcile(project)) - return - } - setGlobalStore( - "projects", - produce((draft) => { - draft.splice(result.index, 0, project) - }), - ) - }) + const result = Binary.search(globalStore.projects, event.properties.id, (s) => s.id) + if (result.found) { + setGlobalStore("projects", result.index, reconcile(event.properties)) + return + } + setGlobalStore( + "projects", + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) break } } @@ -216,14 +183,16 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple Promise.all([ sdk.client.project.list().then(async (x) => { - const filtered = x.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) - const usedColors = new Set(filtered.map((p) => p.icon?.color).filter(Boolean) as string[]) - const projects = await Promise.all(filtered.map((p) => ensureProjectColor(p, sdk, usedColors))) setGlobalStore( "projects", - projects.sort((a, b) => a.id.localeCompare(b.id)), + x + .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) + .sort((a, b) => a.id.localeCompare(b.id)), ) }), + sdk.client.provider.list().then((x) => { + setGlobalStore("providers", x.data ?? []) + }), ]).then(() => setGlobalStore("ready", true)) return { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 05a47c4eb7c..13c4679d69f 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -4,6 +4,20 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" +import { Project } from "@opencode-ai/sdk/v2" + +const PASTEL_COLORS = [ + "#FCEAFD", // pastel pink + "#FFDFBA", // pastel peach + "#FFFFBA", // pastel yellow + "#BAFFC9", // pastel green + "#EAF6FD", // pastel blue + "#EFEAFD", // pastel lavender + "#FEC8D8", // pastel rose + "#D4F0F0", // pastel cyan + "#FDF0EA", // pastel coral + "#C1E1C1", // pastel mint +] export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", @@ -30,6 +44,42 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, ) + function pickAvailableColor() { + const available = PASTEL_COLORS.filter((c) => !colors().has(c)) + if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] + return available[Math.floor(Math.random() * available.length)] + } + + function enrich(project: { worktree: string; expanded: boolean }) { + const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree) + if (!metadata) return [] + return [ + { + ...project, + ...metadata, + }, + ] + } + + function colorize(project: Project & { expanded: boolean }) { + if (project.icon?.color) return project + const color = pickAvailableColor() + project.icon = { ...project.icon, color } + globalSdk.client.project.update({ projectID: project.id, icon: { color } }) + return project + } + + const enriched = createMemo(() => store.projects.flatMap(enrich)) + const list = createMemo(() => enriched().flatMap(colorize)) + const colors = createMemo( + () => + new Set( + list() + .map((p) => p.icon?.color) + .filter(Boolean), + ), + ) + async function loadProjectSessions(directory: string) { const [, setStore] = globalSync.child(directory) globalSdk.client.session.list({ directory }).then((x) => { @@ -43,26 +93,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( onMount(() => { Promise.all( - store.projects.map(({ worktree }) => { - return loadProjectSessions(worktree) + store.projects.map((project) => { + return loadProjectSessions(project.worktree) }), ) }) - function enrich(project: { worktree: string; expanded: boolean }) { - const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree) - if (!metadata) return [] - return [ - { - ...project, - ...metadata, - }, - ] - } - return { projects: { - list: createMemo(() => store.projects.flatMap(enrich)), + list, open(directory: string) { if (store.projects.find((x) => x.worktree === directory)) return loadProjectSessions(directory) diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 4a17d01bd65..3e009475632 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -29,6 +29,8 @@ 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" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -44,11 +46,16 @@ export default function Layout(props: ParentProps) { const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const providers = createMemo(() => globalSync.data.providers) const hasProviders = createMemo(() => { const [projectStore] = globalSync.child(currentDirectory()) return projectStore.provider.filter((p) => p.id !== "opencode").length > 0 }) + createEffect(() => { + console.log(providers()) + }) + function navigateToProject(directory: string | undefined) { if (!directory) return const lastSession = store.lastSession[directory] @@ -550,6 +557,37 @@ export default function Layout(props: ParentProps) {
{props.children}
+ + x?.id} + items={providers()} + // current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + // groupBy={(x) => x.provider.name} + onSelect={(x) => + // local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) + { + return + } + } + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+
) diff --git a/packages/tauri/src-tauri/Cargo.lock b/packages/tauri/src-tauri/Cargo.lock index 57d463355ed..f2e77a1e806 100644 --- a/packages/tauri/src-tauri/Cargo.lock +++ b/packages/tauri/src-tauri/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "OpenCode" +version = "0.0.0" +dependencies = [ + "listeners", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-updater", + "tokio", +] + [[package]] name = "adler2" version = "2.0.1" @@ -2500,23 +2517,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "opencode-desktop" -version = "0.0.0" -dependencies = [ - "listeners", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-opener", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-updater", - "tokio", -] - [[package]] name = "option-ext" version = "0.2.0" diff --git a/packages/tauri/src-tauri/Cargo.toml b/packages/tauri/src-tauri/Cargo.toml index c6b0e409b0e..3d7bf654dd9 100644 --- a/packages/tauri/src-tauri/Cargo.toml +++ b/packages/tauri/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "opencode-desktop" +name = "OpenCode" version = "0.0.0" description = "A Tauri App" authors = ["you"] diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index 1ff3008eeb2..fb5798b0854 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -9,22 +9,23 @@ export interface AvatarProps extends ComponentProps<"div"> { export function Avatar(props: AvatarProps) { const [split, rest] = splitProps(props, ["fallback", "src", "background", "size", "class", "classList", "style"]) + const src = split.src // did this so i can zero it out to test fallback return (
- + {(src) => }
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 192c7b60ca2..f9531702806 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -102,12 +102,20 @@ height: 24px; padding: 0 6px; &[data-icon] { - padding: 0 8px 0 6px; + padding: 0 12px 0 4px; } font-size: var(--font-size-small); line-height: var(--line-height-large); gap: 6px; + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); } &[data-size="large"] { @@ -115,17 +123,17 @@ padding: 0 8px; &[data-icon] { - padding: 0 8px 0 6px; + padding: 0 12px 0 8px; } gap: 8px; - /* text-12-medium */ + /* text-14-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); /* 142.857% */ letter-spacing: var(--letter-spacing-normal); } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index 206eade0d82..cc834f79503 100644 --- a/packages/ui/src/components/select-dialog.css +++ b/packages/ui/src/components/select-dialog.css @@ -75,7 +75,6 @@ position: relative; display: flex; flex-direction: column; - gap: 4px; [data-slot="select-dialog-header"] { display: flex; diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 695791aad4d..b93993ad40e 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -15,6 +15,7 @@ interface SelectDialogProps children: (item: T) => JSX.Element onSelect?: (value: T | undefined) => void onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void + actions?: JSX.Element } export function SelectDialog(props: SelectDialogProps) { @@ -98,7 +99,8 @@ export function SelectDialog(props: SelectDialogProps) { {others.title} - + {others.actions} +
@@ -136,7 +138,7 @@ export function SelectDialog(props: SelectDialogProps) { fallback={
- {props.emptyMessage ?? "No search results"} for{" "} + {props.emptyMessage ?? "No results"} for{" "} "{filter()}"
From 190fa4c87aa2b3f954a419f716add1fc29e4011e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:48:08 -0600 Subject: [PATCH 17/70] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 111 ++++++++++-------- packages/desktop/src/context/global-sync.tsx | 26 ++-- packages/desktop/src/context/layout.tsx | 20 +++- packages/desktop/src/context/local.tsx | 38 +++--- packages/desktop/src/context/session.tsx | 2 +- packages/desktop/src/context/sync.tsx | 6 +- packages/desktop/src/pages/home.tsx | 4 +- packages/desktop/src/pages/layout.tsx | 90 +++++++++----- .../enterprise/src/routes/share/[shareID].tsx | 2 +- packages/ui/src/components/provider-icon.tsx | 6 +- packages/ui/src/components/select-dialog.css | 12 +- packages/ui/src/components/select-dialog.tsx | 10 +- 12 files changed, 201 insertions(+), 126 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 97d27ee1e4a..985dbae8e27 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -16,6 +16,7 @@ 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" interface PromptInputProps { class?: string @@ -56,6 +57,7 @@ export const PromptInput: Component = (props) => { const sync = useSync() const local = useLocal() const session = useSession() + const layout = useLayout() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -453,54 +455,67 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> - `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (order.includes(aProvider) && !order.includes(bProvider)) return -1 - if (!order.includes(aProvider) && order.includes(bProvider)) return 1 - return order.indexOf(aProvider) - order.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - } - trigger={ - - } - actions={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
+ + + { + if (open) { + layout.dialog.open("model") + } else { + layout.dialog.close("model") + } + }} + title="Select model" + placeholder="Search models" + emptyMessage="No model results" + key={(x) => `${x.provider.id}:${x.id}`} + items={local.model.list()} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (order.includes(aProvider) && !order.includes(bProvider)) return -1 + if (!order.includes(aProvider) && order.includes(bProvider)) return 1 + return order.indexOf(aProvider) - order.indexOf(bProvider) + }} + onSelect={(x) => + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) + } + actions={ + + } + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+
{ + const sdk = useGlobalSDK() const [globalStore, setGlobalStore] = createStore<{ ready: boolean - projects: Project[] - providers: Provider[] + project: Project[] + provider: ProviderListResponse children: Record }>({ ready: false, - projects: [], - providers: [], + project: [], + provider: { all: [], connected: [], default: {} }, children: {}, }) @@ -66,11 +67,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple if (!children[directory]) { setGlobalStore("children", directory, { project: "", + provider: { all: [], connected: [], default: {} }, config: {}, path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], - // provider: [], session: [], session_status: {}, session_diff: {}, @@ -86,7 +87,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple return children[directory] } - const sdk = useGlobalSDK() sdk.event.listen((e) => { const directory = e.name const event = e.details @@ -94,13 +94,13 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple if (directory === "global") { switch (event.type) { case "project.updated": { - const result = Binary.search(globalStore.projects, event.properties.id, (s) => s.id) + const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id) if (result.found) { - setGlobalStore("projects", result.index, reconcile(event.properties)) + setGlobalStore("project", result.index, reconcile(event.properties)) return } setGlobalStore( - "projects", + "project", produce((draft) => { draft.splice(result.index, 0, event.properties) }), @@ -184,14 +184,14 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple Promise.all([ sdk.client.project.list().then(async (x) => { setGlobalStore( - "projects", + "project", x .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) .sort((a, b) => a.id.localeCompare(b.id)), ) }), sdk.client.provider.list().then((x) => { - setGlobalStore("providers", x.data ?? []) + setGlobalStore("provider", x.data ?? {}) }), ]).then(() => setGlobalStore("ready", true)) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 13c4679d69f..1de8550cb1d 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -40,9 +40,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "default-layout.v6", + name: "default-layout.v7", }, ) + const [ephemeral, setEphemeral] = createStore({ + dialog: { + open: undefined as undefined | "provider" | "model", + }, + }) function pickAvailableColor() { const available = PASTEL_COLORS.filter((c) => !colors().has(c)) @@ -51,7 +56,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } function enrich(project: { worktree: string; expanded: boolean }) { - const metadata = globalSync.data.projects.find((x) => x.worktree === project.worktree) + const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree) if (!metadata) return [] return [ { @@ -168,6 +173,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("review", "state", "tab") }, }, + dialog: { + opened: createMemo(() => ephemeral.dialog?.open), + open(dialog: "provider" | "model") { + setEphemeral("dialog", "open", dialog) + }, + close(dialog: "provider" | "model") { + if (ephemeral.dialog?.open === dialog) { + setEphemeral("dialog", "open", undefined) + } + }, + }, } }, }) diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 58a65b0de8c..74d3ac364c5 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -39,8 +39,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sync = useSync() function isModelValid(model: ModelKey) { - const provider = sync.data.provider.find((x) => x.id === model.providerID) - return !!provider?.models[model.modelID] + const provider = sync.data.provider?.all.find((x) => x.id === model.providerID) + return !!provider?.models[model.modelID] && sync.data.provider?.connected.includes(model.providerID) } function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { @@ -115,17 +115,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const list = createMemo(() => - sync.data.provider.flatMap((p) => - Object.values(p.models).map( - (m) => - ({ - ...m, - name: m.name.replace("(latest)", "").trim(), - provider: p, - latest: m.name.includes("(latest)"), - }) as LocalModel, + sync.data.provider.all + .filter((p) => sync.data.provider.connected.includes(p.id)) + .flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + provider: p, + latest: m.name.includes("(latest)"), + })), ), - ), ) const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) @@ -145,12 +144,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return item } } - const provider = sync.data.provider[0] - const model = Object.values(provider.models)[0] - return { - providerID: provider.id, - modelID: model.id, + + for (const p of sync.data.provider.connected) { + if (p in sync.data.provider.default) { + return { + providerID: p, + modelID: sync.data.provider.default[p], + } + } } + + throw new Error("No default model found") }) const currentModel = createMemo(() => { diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 31004811bcb..db2b3af7c34 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -94,7 +94,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage, ) const model = createMemo(() => - last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, + last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined, ) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 85986c32714..1a11cd5998e 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const load = { project: () => sdk.client.project.current().then((x) => setStore("project", x.data!.id)), - provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)), + provider: () => sdk.client.provider.list().then((x) => setStore("provider", x.data!)), path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), session: () => @@ -42,8 +42,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return store.ready }, get project() { - const match = Binary.search(globalSync.data.projects, store.project, (p) => p.id) - if (match.found) return globalSync.data.projects[match.index] + const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) + if (match.found) return globalSync.data.project[match.index] return undefined }, session: { diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index 4aac241e13c..205ffd81572 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -38,7 +38,7 @@ export default function Home() {
- 0}> + 0}>
Recent projects
@@ -50,7 +50,7 @@ export default function Home() {
    (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) .slice(0, 5)} > diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 3e009475632..2ea6c4ba021 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -9,6 +9,7 @@ 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" @@ -31,6 +32,9 @@ import { 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" + +const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -46,15 +50,18 @@ export default function Layout(props: ParentProps) { const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) - const providers = createMemo(() => globalSync.data.providers) - const hasProviders = createMemo(() => { - const [projectStore] = globalSync.child(currentDirectory()) - return projectStore.provider.filter((p) => p.id !== "opencode").length > 0 - }) - - createEffect(() => { - console.log(providers()) + const providers = createMemo(() => { + if (currentDirectory()) { + const [projectStore] = globalSync.child(currentDirectory()) + return projectStore.provider + } + return globalSync.data.provider }) + const connectedProviders = createMemo(() => + providers().all.filter( + (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input), + ), + ) function navigateToProject(directory: string | undefined) { if (!directory) return @@ -93,7 +100,9 @@ export default function Layout(props: ParentProps) { } } - async function connectProvider() {} + async function connectProvider() { + layout.dialog.open("provider") + } createEffect(() => { if (!params.dir || !params.id) return @@ -484,7 +493,7 @@ export default function Layout(props: ParentProps) {
- +
Getting started
@@ -493,7 +502,7 @@ export default function Layout(props: ParentProps) {
{props.children}
- + x?.id} - items={providers()} + items={providers().all} // current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - // groupBy={(x) => x.provider.name} - onSelect={(x) => - // local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - { - return + 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) => } + onOpenChange={(open) => { + if (open) { + layout.dialog.open("provider") + } else { + layout.dialog.close("provider") } - } + }} > {(i) => ( -
+
+ {i.name} - - Free + + Recommended - - Latest + +
Connect with Claude Pro/Max or API key
)} diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 15a36b2ff48..1c593ca87eb 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -212,7 +212,7 @@ export default function () {
v{info().version}
- +
{model()?.name ?? modelID()}
diff --git a/packages/ui/src/components/provider-icon.tsx b/packages/ui/src/components/provider-icon.tsx index 924dcd25c5a..d653765a554 100644 --- a/packages/ui/src/components/provider-icon.tsx +++ b/packages/ui/src/components/provider-icon.tsx @@ -4,11 +4,11 @@ import sprite from "./provider-icons/sprite.svg" import type { IconName } from "./provider-icons/types" export type ProviderIconProps = JSX.SVGElementTags["svg"] & { - name: IconName + id: IconName } export const ProviderIcon: Component = (props) => { - const [local, rest] = splitProps(props, ["name", "class", "classList"]) + const [local, rest] = splitProps(props, ["id", "class", "classList"]) return ( = (props) => { [local.class ?? ""]: !!local.class, }} > - + ) } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index cc834f79503..f5687ad8eea 100644 --- a/packages/ui/src/components/select-dialog.css +++ b/packages/ui/src/components/select-dialog.css @@ -11,7 +11,7 @@ display: flex; height: 40px; flex-shrink: 0; - padding: 4px 10px 4px 6px; + padding: 4px 10px 4px 16px; align-items: center; gap: 12px; align-self: stretch; @@ -121,6 +121,9 @@ letter-spacing: var(--letter-spacing-normal); [data-slot="select-dialog-item-selected-icon"] { + color: var(--icon-strong-base); + } + [data-slot="select-dialog-item-active-icon"] { display: none; color: var(--icon-strong-base); } @@ -128,12 +131,13 @@ &[data-active="true"] { border-radius: var(--radius-md); background: var(--surface-raised-base-hover); - } - &[data-selected="true"] { - [data-slot="select-dialog-item-selected-icon"] { + [data-slot="select-dialog-item-active-icon"] { display: block; } } + &:active { + background: var(--surface-raised-base-active); + } } } } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index b93993ad40e..86f72322575 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -2,7 +2,7 @@ import { createEffect, Show, For, type JSX, splitProps, createSignal } from "sol import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" +import { Icon, IconProps } from "./icon" import { Input } from "./input" import { IconButton } from "./icon-button" @@ -16,6 +16,7 @@ interface SelectDialogProps onSelect?: (value: T | undefined) => void onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void actions?: JSX.Element + activeIcon?: IconProps["name"] } export function SelectDialog(props: SelectDialogProps) { @@ -165,7 +166,12 @@ export function SelectDialog(props: SelectDialogProps) { }} > {others.children(item)} - + + + + + {(icon) => } + )} From 58e66dd3d1dfd975195dac916fb4b23093404243 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:16:57 -0600 Subject: [PATCH 18/70] wip(desktop): progress --- packages/ui/src/components/list.css | 107 +++++++++++++ packages/ui/src/components/list.tsx | 141 +++++++++++++++++ packages/ui/src/components/select-dialog.css | 118 ++------------- packages/ui/src/components/select-dialog.tsx | 150 ++++--------------- packages/ui/src/styles/index.css | 1 + 5 files changed, 290 insertions(+), 227 deletions(-) create mode 100644 packages/ui/src/components/list.css create mode 100644 packages/ui/src/components/list.tsx diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css new file mode 100644 index 00000000000..63d9a2fe1e4 --- /dev/null +++ b/packages/ui/src/components/list.css @@ -0,0 +1,107 @@ +[data-component="list"] { + display: flex; + flex-direction: column; + gap: 20px; + + [data-slot="list-empty-state"] { + display: flex; + padding: 32px 0px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 8px; + align-self: stretch; + + [data-slot="list-message"] { + display: flex; + justify-content: center; + align-items: center; + gap: 2px; + color: var(--text-weak); + text-align: center; + + /* 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="list-filter"] { + color: var(--text-strong); + } + } + + [data-slot="list-group"] { + position: relative; + display: flex; + flex-direction: column; + + [data-slot="list-header"] { + display: flex; + height: 28px; + padding: 0 10px; + justify-content: space-between; + align-items: center; + align-self: stretch; + background: var(--surface-raised-stronger-non-alpha); + position: sticky; + top: 0; + + color: var(--text-base); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="list-items"] { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + + [data-slot="list-item"] { + display: flex; + width: 100%; + height: 28px; + padding: 4px 10px; + align-items: center; + color: var(--text-strong); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + [data-slot="list-item-selected-icon"] { + color: var(--icon-strong-base); + } + [data-slot="list-item-active-icon"] { + display: none; + color: var(--icon-strong-base); + } + + &[data-active="true"] { + border-radius: var(--radius-md); + background: var(--surface-raised-base-hover); + [data-slot="list-item-active-icon"] { + display: block; + } + } + &:active { + background: var(--surface-raised-base-active); + } + } + } + } +} diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx new file mode 100644 index 00000000000..3fbeb35f604 --- /dev/null +++ b/packages/ui/src/components/list.tsx @@ -0,0 +1,141 @@ +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" + +export interface ListProps extends FilteredListProps { + children: (item: T) => JSX.Element + emptyMessage?: string + onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void + activeIcon?: IconProps["name"] + filter?: string +} + +export interface ListRef { + onKeyDown: (e: KeyboardEvent) => void + setScrollRef: (el: HTMLDivElement | undefined) => void +} + +export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { + const [scrollRef, setScrollRef] = createSignal(undefined) + 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, + }) + + createEffect(() => { + if (props.filter === undefined) return + onInput(props.filter) + }) + + createEffect(() => { + filter() + scrollRef()?.scrollTo(0, 0) + reset() + }) + + createEffect(() => { + if (!scrollRef()) return + if (!props.current) return + const key = props.key(props.current) + requestAnimationFrame(() => { + const element = scrollRef()!.querySelector(`[data-key="${key}"]`) + element?.scrollIntoView({ block: "center" }) + }) + }) + + createEffect(() => { + const all = flat() + if (store.mouseActive || all.length === 0) return + if (active() === props.key(all[0])) { + scrollRef()?.scrollTo(0, 0) + return + } + const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + + const handleSelect = (item: T | undefined) => { + props.onSelect?.(item) + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + if (e.key === "Escape") return + + const all = flat() + const selected = all.find((x) => props.key(x) === active()) + props.onKeyEvent?.(e, selected) + + if (e.key === "Enter") { + e.preventDefault() + if (selected) handleSelect(selected) + } else { + onKeyDown(e) + } + } + + props.ref?.({ + onKeyDown: handleKey, + setScrollRef, + }) + + return ( +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}" +
+
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item) => ( + + )} + +
+
+ )} +
+
+
+ ) +} diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index f5687ad8eea..9759174a6d0 100644 --- a/packages/ui/src/components/select-dialog.css +++ b/packages/ui/src/components/select-dialog.css @@ -5,6 +5,14 @@ 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"] { @@ -22,7 +30,7 @@ [data-slot="select-dialog-input-container"] { display: flex; align-items: center; - gap: 12px; + gap: 16px; flex: 1 0 0; /* [data-slot="select-dialog-icon"] {} */ @@ -34,111 +42,3 @@ /* [data-slot="select-dialog-clear-button"] {} */ } - -[data-component="select-dialog"] { - display: flex; - flex-direction: column; - gap: 20px; - - [data-slot="select-dialog-empty-state"] { - display: flex; - padding: 32px 0px; - flex-direction: column; - justify-content: center; - align-items: center; - gap: 8px; - align-self: stretch; - - [data-slot="select-dialog-message"] { - display: flex; - justify-content: center; - align-items: center; - gap: 2px; - color: var(--text-weak); - text-align: center; - - /* 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="select-dialog-filter"] { - color: var(--text-strong); - } - } - - [data-slot="select-dialog-group"] { - position: relative; - display: flex; - flex-direction: column; - - [data-slot="select-dialog-header"] { - display: flex; - height: 28px; - padding: 0 10px; - justify-content: space-between; - align-items: center; - align-self: stretch; - background: var(--surface-raised-stronger-non-alpha); - position: sticky; - top: 0; - - color: var(--text-base); - - /* text-14-medium */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - } - - [data-slot="select-dialog-list"] { - display: flex; - flex-direction: column; - align-items: flex-start; - align-self: stretch; - - [data-slot="select-dialog-item"] { - display: flex; - width: 100%; - height: 28px; - padding: 4px 10px; - align-items: center; - color: var(--text-strong); - - /* text-14-medium */ - font-family: var(--font-family-sans); - font-size: 14px; - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 142.857% */ - letter-spacing: var(--letter-spacing-normal); - - [data-slot="select-dialog-item-selected-icon"] { - color: var(--icon-strong-base); - } - [data-slot="select-dialog-item-active-icon"] { - display: none; - color: var(--icon-strong-base); - } - - &[data-active="true"] { - border-radius: var(--radius-md); - background: var(--surface-raised-base-hover); - [data-slot="select-dialog-item-active-icon"] { - display: block; - } - } - &:active { - background: var(--surface-raised-base-active); - } - } - } - } -} diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 86f72322575..952ba881f73 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,98 +1,46 @@ -import { createEffect, Show, For, type JSX, splitProps, createSignal } from "solid-js" -import { createStore } from "solid-js/store" -import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" +import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" -import { Icon, IconProps } from "./icon" +import { Icon } from "./icon" import { Input } from "./input" import { IconButton } from "./icon-button" +import { List, ListRef, ListProps } from "./list" interface SelectDialogProps - extends FilteredListProps, + extends Omit, "filter">, Pick { title: string placeholder?: string - emptyMessage?: string - children: (item: T) => JSX.Element - onSelect?: (value: T | undefined) => void - onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void actions?: JSX.Element - activeIcon?: IconProps["name"] } export function SelectDialog(props: SelectDialogProps) { const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) let closeButton!: HTMLButtonElement let inputRef: HTMLInputElement | undefined - let [scrollRef, setScrollRef] = createSignal(undefined) - const [store, setStore] = createStore({ - mouseActive: false, - }) - - const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList({ - items: others.items, - key: others.key, - filterKeys: others.filterKeys, - current: others.current, - groupBy: others.groupBy, - sortBy: others.sortBy, - sortGroupsBy: others.sortGroupsBy, - }) - - createEffect(() => { - filter() - scrollRef()?.scrollTo(0, 0) - reset() - }) + const [filter, setFilter] = createSignal("") + let listRef: ListRef | undefined createEffect(() => { - if (!scrollRef()) return - if (!others.current) return - const key = others.key(others.current) + if (!props.current) return + const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()!.querySelector(`[data-key="${key}"]`) + const element = document.querySelector(`[data-key="${key}"]`) element?.scrollIntoView({ block: "center" }) }) }) - createEffect(() => { - const all = flat() - if (store.mouseActive || all.length === 0) return - if (active() === others.key(all[0])) { - scrollRef()?.scrollTo(0, 0) - return - } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - - const handleInput = (value: string) => { - onInput(value) - reset() - } - const handleSelect = (item: T | undefined) => { others.onSelect?.(item) closeButton.click() } const handleKey = (e: KeyboardEvent) => { - setStore("mouseActive", false) if (e.key === "Escape") return - - const all = flat() - const selected = all.find((x) => others.key(x) === active()) - props.onKeyEvent?.(e, selected) - - if (e.key === "Enter") { - e.preventDefault() - if (selected) handleSelect(selected) - } else { - onKeyDown(e) - } + listRef?.onKeyDown(e) } const handleOpenChange = (open: boolean) => { - if (!open) clear() + if (!open) setFilter("") props.onOpenChange?.(open) } @@ -113,7 +61,7 @@ export function SelectDialog(props: SelectDialogProps) { data-slot="select-dialog-input" type="text" value={filter()} - onChange={(value) => handleInput(value)} + onChange={setFilter} onKeyDown={handleKey} placeholder={others.placeholder} spellcheck={false} @@ -123,63 +71,29 @@ export function SelectDialog(props: SelectDialogProps) { />
- { - onInput("") - reset() - }} - /> + setFilter("")} />
- - 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for{" "} - "{filter()}" -
-
- } + + { + 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} > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item) => ( - - )} - -
-
- )} -
-
+ {others.children} +
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 074859f352f..4c7f6e80ba1 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -22,6 +22,7 @@ @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); +@import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); From 86f7cc17ae81fd36f3f2fce22439773002f3fd3a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:17:27 -0500 Subject: [PATCH 19/70] tui: pass dynamic port to frontend Frontend now receives the server port via window.__OPENCODE__.port, allowing it to connect when using a random free port instead of hardcoded 4096 --- packages/desktop/src/app.tsx | 8 +++++++- packages/tauri/src-tauri/src/lib.rs | 3 ++- packages/tauri/src/index.tsx | 6 ------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx index 0ca4d5e6be7..a1ff90d2694 100644 --- a/packages/desktop/src/app.tsx +++ b/packages/desktop/src/app.tsx @@ -15,8 +15,14 @@ import { GlobalSDKProvider } from "./context/global-sdk" import { SessionProvider } from "./context/session" import { Show } from "solid-js" +declare global { + interface Window { + __OPENCODE__?: { updaterEnabled?: boolean; port?: number } + } +} + const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" -const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" +const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" const url = new URLSearchParams(document.location.search).get("url") || diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs index d380e35768d..d7993257401 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -175,7 +175,8 @@ pub fn run() { .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; - window.__OPENCODE__.updaterEnabled = {updater_enabled} + window.__OPENCODE__.updaterEnabled = {updater_enabled}; + window.__OPENCODE__.port = {port}; "# )); diff --git a/packages/tauri/src/index.tsx b/packages/tauri/src/index.tsx index 6b9ce88e01d..c72805fe648 100644 --- a/packages/tauri/src/index.tsx +++ b/packages/tauri/src/index.tsx @@ -47,12 +47,6 @@ const platform: Platform = { }, } -declare global { - interface Window { - __OPENCODE__?: { updaterEnabled?: boolean } - } -} - render(() => { onMount(() => { if (window.__OPENCODE__?.updaterEnabled) runUpdater() From e060f968f5f87b9e176ce7af41fd82f015ec54b0 Mon Sep 17 00:00:00 2001 From: Github Action Date: Wed, 10 Dec 2025 21:18:57 +0000 Subject: [PATCH 20/70] 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 a9117fa8569..ee38c07f5ec 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-JT8J+Nd2kk0x46BcyotmBbM39tuKOW7VzXfOV3R3sqQ=" + "nodeModules": "sha256-WQMQmqKojxdRtwv6KL9HBaDfwYa4qPn2pvXKqgNM73A=" } From 7435d94f85364654dda80c7b41d9c2379ebad640 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:55:15 +0100 Subject: [PATCH 21/70] fix(cli): obtain directory data from server (#5320) --- packages/opencode/src/cli/cmd/tui/context/directory.ts | 3 ++- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 2ea8cf007d2..4664f2a8812 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -5,7 +5,8 @@ import { Global } from "@/global" export function useDirectory() { const sync = useSync() return createMemo(() => { - const result = process.cwd().replace(Global.Path.home, "~") + const directory = sync.data.path.directory ?? process.cwd() + const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 28ea60a67ff..f74f787db8c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -24,6 +24,7 @@ import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" +import type { Path } from "@opencode-ai/sdk" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -62,6 +63,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } formatter: FormatterStatus[] vcs: VcsInfo | undefined + path: Path }>({ provider_next: { all: [], @@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, formatter: [], vcs: undefined, + path: { state: "", config: "", worktree: "", directory: "" }, }) const sdk = useSDK() @@ -286,6 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.status().then((x) => setStore("session_status", x.data!)), sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})), sdk.client.vcs.get().then((x) => setStore("vcs", x.data)), + sdk.client.path.get().then((x) => setStore("path", x.data!)), ]).then(() => { setStore("status", "complete") }) From 7d82f1769cbde45384467308106099610fb7810a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 16:01:10 -0600 Subject: [PATCH 22/70] tweak: small fix --- packages/opencode/src/cli/cmd/tui/context/directory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/directory.ts b/packages/opencode/src/cli/cmd/tui/context/directory.ts index 4664f2a8812..17e5c180a19 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -5,7 +5,7 @@ import { Global } from "@/global" export function useDirectory() { const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory ?? process.cwd() + const directory = sync.data.path.directory || process.cwd() const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result From e46080aa8c34ef3132d76b412cc6e750a2f16b32 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 11 Dec 2025 06:23:12 +0800 Subject: [PATCH 23/70] fix(auth): add plugin lookup for custom provider in 'Other' flow (#5324) --- packages/opencode/src/cli/cmd/auth.ts | 294 ++++++++++++++------------ 1 file changed, 158 insertions(+), 136 deletions(-) diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 61fe4e5bdef..658329fb6ef 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -10,6 +10,154 @@ import { Config } from "../../config/config" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +import type { Hooks } from "@opencode-ai/plugin" + +type PluginAuth = NonNullable + +/** + * Handle plugin-based authentication flow. + * Returns true if auth was handled, false if it should fall through to default handling. + */ +async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { + let index = 0 + if (plugin.auth.methods.length > 1) { + const method = await prompts.select({ + message: "Login method", + options: [ + ...plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index.toString(), + })), + ], + }) + if (prompts.isCancel(method)) throw new UI.CancelledError() + index = parseInt(method) + } + const method = plugin.auth.methods[index] + + // Handle prompts for all auth types + await new Promise((resolve) => setTimeout(resolve, 10)) + const inputs: Record = {} + if (method.prompts) { + for (const prompt of method.prompts) { + if (prompt.condition && !prompt.condition(inputs)) { + continue + } + if (prompt.type === "select") { + const value = await prompts.select({ + message: prompt.message, + options: prompt.options, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } else { + const value = await prompts.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } + } + } + + if (method.type === "oauth") { + const authorize = await method.authorize(inputs) + + if (authorize.url) { + prompts.log.info("Go to: " + authorize.url) + } + + if (authorize.method === "auto") { + if (authorize.instructions) { + prompts.log.info(authorize.instructions) + } + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + const result = await authorize.callback() + if (result.type === "failed") { + spinner.stop("Failed to authorize", 1) + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + spinner.stop("Login successful") + } + } + + if (authorize.method === "code") { + const code = await prompts.text({ + message: "Paste the authorization code here: ", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(code)) throw new UI.CancelledError() + const result = await authorize.callback(code) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + prompts.log.success("Login successful") + } + } + + prompts.outro("Done") + return true + } + + if (method.type === "api") { + if (method.authorize) { + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + prompts.log.success("Login successful") + } + prompts.outro("Done") + return true + } + } + + return false +} export const AuthCommand = cmd({ command: "auth", @@ -160,142 +308,8 @@ export const AuthLoginCommand = cmd({ const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { - let index = 0 - if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: [ - ...plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - ], - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } - const method = plugin.auth.methods[index] - - // Handle prompts for all auth types - await new Promise((resolve) => setTimeout(resolve, 10)) - const inputs: Record = {} - if (method.prompts) { - for (const prompt of method.prompts) { - if (prompt.condition && !prompt.condition(inputs)) { - continue - } - if (prompt.type === "select") { - const value = await prompts.select({ - message: prompt.message, - options: prompt.options, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } - } - } - - if (method.type === "oauth") { - const authorize = await method.authorize(inputs) - - if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) - } - - if (authorize.method === "auto") { - if (authorize.instructions) { - prompts.log.info(authorize.instructions) - } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() - if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) - } - if ("key" in result) { - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - } - spinner.stop("Login successful") - } - } - - if (authorize.method === "code") { - const code = await prompts.text({ - message: "Paste the authorization code here: ", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) - } - if ("key" in result) { - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - } - prompts.log.success("Login successful") - } - } - - prompts.outro("Done") - return - } - - if (method.type === "api") { - if (method.authorize) { - const result = await method.authorize(inputs) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - prompts.log.success("Login successful") - } - prompts.outro("Done") - return - } - } + const handled = await handlePluginAuth({ auth: plugin.auth }, provider) + if (handled) return } if (provider === "other") { @@ -306,6 +320,14 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() provider = provider.replace(/^@ai-sdk\//, "") if (prompts.isCancel(provider)) throw new UI.CancelledError() + + // Check if a plugin provides auth for this custom provider + const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + if (handled) return + } + prompts.log.warn( `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) From 72eb004057a08283f734c7543c08f399f8ba881b Mon Sep 17 00:00:00 2001 From: Hammad Shami <46585994+H2Shami@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:23:52 -0800 Subject: [PATCH 24/70] feat: add helicone docs + helicone session tracking (#5265) --- packages/web/src/content/docs/ecosystem.mdx | 1 + packages/web/src/content/docs/providers.mdx | 113 ++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 845ac633300..77584f1a53d 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -17,6 +17,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | 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 | diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 8405966f717..5f9b040d4d2 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -568,6 +568,119 @@ The `global` region improves availability and reduces errors at no extra cost. U --- +### Helicone + +[Helicone](https://helicone.ai) is an LLM observability platform that provides logging, monitoring, and analytics for your AI applications. The Helicone AI Gateway routes your requests to the appropriate provider automatically based on the model. + +1. Head over to [Helicone](https://helicone.ai), create an account, and generate an API key from your dashboard. + +2. Run the `/connect` command and search for **Helicone**. + + ```txt + /connect + ``` + +3. Enter your Helicone API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +For more providers and advanced features like caching and rate limiting, check the [Helicone documentation](https://docs.helicone.ai). + +#### Optional Configs + +In the event you see a feature or model from Helicone that isn't configured automatically through opencode, you can always configure it yourself. + +Here's [Helicone's Model Directory](https://helicone.ai/models), you'll need this to grab the IDs of the models you want to add. + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + }, + "models": { + "gpt-4o": { + // Model ID (from Helicone's model directory page) + "name": "GPT-4o", // Your own custom name for the model + }, + "claude-sonnet-4-20250514": { + "name": "Claude Sonnet 4", + }, + }, + }, + }, +} +``` + +#### Custom Headers + +Helicone supports custom headers for features like caching, user tracking, and session management. Add them to your provider config using `options.headers`: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + "headers": { + "Helicone-Cache-Enabled": "true", + "Helicone-User-Id": "opencode", + }, + }, + }, + }, +} +``` + +##### Session tracking + +Helicone's [Sessions](https://docs.helicone.ai/features/sessions) feature lets you group related LLM requests together. Use the [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) plugin to automatically log each OpenCode conversation as a session in Helicone. + +```bash +npm install -g opencode-helicone-session +``` + +Add it to your config. + +```json title="opencode.json" +{ + "plugin": ["opencode-helicone-session"] +} +``` + +The plugin injects `Helicone-Session-Id` and `Helicone-Session-Name` headers into your requests. In Helicone's Sessions page, you'll see each OpenCode conversation listed as a separate session. + +##### Common Helicone headers + +| Header | Description | +| -------------------------- | ------------------------------------------------------------- | +| `Helicone-Cache-Enabled` | Enable response caching (`true`/`false`) | +| `Helicone-User-Id` | Track metrics by user | +| `Helicone-Property-[Name]` | Add custom properties (e.g., `Helicone-Property-Environment`) | +| `Helicone-Prompt-Id` | Associate requests with prompt versions | + +See the [Helicone Header Directory](https://docs.helicone.ai/helicone-headers/header-directory) for all available headers. + +--- + ### llama.cpp You can configure opencode to use local models through [llama.cpp's](https://github.com/ggml-org/llama.cpp) llama-server utility From b274371dbb2b6853636a063907371418f7cbae46 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 14:36:11 -0800 Subject: [PATCH 25/70] feat: use |- for intermediate sub-agent steps (#5336) Signed-off-by: Christian Stewart --- .../src/cli/cmd/tui/routes/session/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 628235afd0b..185c0a5c361 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1503,11 +1503,15 @@ ToolRegistry.register({ - {(task) => ( - - ∟ {Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""} - - )} + {(task, index) => { + const summary = props.metadata.summary ?? [] + return ( + + {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "} + {task.state.status === "completed" ? task.state.title : ""} + + ) + }} From e36c3492221cf8c225bddb6a74431254de8d54a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 17:06:16 -0600 Subject: [PATCH 26/70] tweak: oc -> OC --- packages/opencode/src/cli/cmd/tui/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 1107ddd6a55..4c501c1e105 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,7 +186,7 @@ function App() { // Truncate title to 40 chars max const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`oc | ${title}`) + renderer.setTerminalTitle(`OC | ${title}`) } }) From cbb591eb7dfe8e27298945f10e5d6cfff4405630 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 15:12:49 -0800 Subject: [PATCH 27/70] fix: more descriptive tool or subtask execution failed error (#5337) Signed-off-by: Christian Stewart --- packages/opencode/src/session/prompt.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 71b99ab0d5c..76c70298258 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -338,6 +338,7 @@ export namespace SessionPrompt { }, }, })) as MessageV2.ToolPart + let executionError: Error | undefined const result = await taskTool .execute( { @@ -362,7 +363,11 @@ export namespace SessionPrompt { }, }, ) - .catch(() => {}) + .catch((error) => { + executionError = error + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }) assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) @@ -388,7 +393,7 @@ export namespace SessionPrompt { ...part, state: { status: "error", - error: "Tool execution failed", + error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", time: { start: part.state.status === "running" ? part.state.time.start : Date.now(), end: Date.now(), From 85cfa226c34e41660ddfdcb04543af2e494ae168 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:17:34 -0600 Subject: [PATCH 28/70] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 221 +++++++++++++----- packages/desktop/src/hooks/use-providers.ts | 31 +++ packages/desktop/src/pages/layout.tsx | 19 +- packages/ui/src/components/input.tsx | 10 +- packages/ui/src/components/list.css | 8 + packages/ui/src/components/list.tsx | 3 +- 6 files changed, 222 insertions(+), 70 deletions(-) create mode 100644 packages/desktop/src/hooks/use-providers.ts diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 985dbae8e27..0672dfc85dd 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -17,6 +17,13 @@ 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 { Input } from "@opencode-ai/ui/input" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" interface PromptInputProps { class?: string @@ -58,6 +65,7 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const layout = useLayout() + const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -461,60 +469,167 @@ export const PromptInput: Component = (props) => { - { - if (open) { - layout.dialog.open("model") - } else { - layout.dialog.close("model") - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={local.model.list()} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - // groupBy={(x) => (local.model.recent().includes(x) ? "Recent" : x.provider.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - const order = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (order.includes(aProvider) && !order.includes(bProvider)) return -1 - if (!order.includes(aProvider) && order.includes(bProvider)) return 1 - return order.indexOf(aProvider) - order.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true }) - } - actions={ - + } > - Connect provider - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
+ {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} + + + + {iife(() => { + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + 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) => { + layout.dialog.close("model") + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
+ Connect with Claude Pro/Max or API key +
+
+
+ )} +
+
+
+
+ +
+ ) + })} +
+
base64Decode(params.dir ?? "")) + const providers = createMemo(() => { + if (currentDirectory()) { + const [projectStore] = globalSync.child(currentDirectory()) + return projectStore.provider + } + return globalSync.data.provider + }) + const connected = createMemo(() => + providers().all.filter( + (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input), + ), + ) + const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) + return createMemo(() => ({ + all: providers().all, + default: providers().default, + popular, + connected, + })) +} diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 2ea6c4ba021..10d4cbfda7e 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -33,8 +33,7 @@ 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" - -const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] +import { popularProviders, useProviders } from "@/hooks/use-providers" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -50,18 +49,7 @@ export default function Layout(props: ParentProps) { const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) - const providers = createMemo(() => { - if (currentDirectory()) { - const [projectStore] = globalSync.child(currentDirectory()) - return projectStore.provider - } - return globalSync.data.provider - }) - const connectedProviders = createMemo(() => - providers().all.filter( - (p) => providers().connected.includes(p.id) && Object.values(p.models).find((m) => m.cost?.input), - ), - ) + const providers = useProviders() function navigateToProject(directory: string | undefined) { if (!directory) return @@ -493,7 +481,7 @@ export default function Layout(props: ParentProps) {
- +
Getting started
@@ -599,6 +587,7 @@ export default function Layout(props: ParentProps) { {(i) => (
, "value" | "onChange" | "onKeyDown">> { label?: string hideLabel?: boolean + hidden?: boolean description?: string } @@ -14,6 +15,7 @@ export function Input(props: InputProps) { const [local, others] = splitProps(props, [ "class", "label", + "hidden", "hideLabel", "description", "value", @@ -21,7 +23,13 @@ export function Input(props: InputProps) { "onKeyDown", ]) return ( - + {local.label} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 63d9a2fe1e4..38dcb773b56 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -97,10 +97,18 @@ [data-slot="list-item-active-icon"] { display: block; } + [data-slot="list-item-extra-icon"] { + color: var(--icon-strong-base) !important; + } } &:active { background: var(--surface-raised-base-active); } + &:hover { + [data-slot="list-item-extra-icon"] { + color: var(--icon-strong-base) !important; + } + } } } } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 3fbeb35f604..a7f2db9efbd 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" export interface ListProps extends FilteredListProps { + class?: string children: (item: T) => JSX.Element emptyMessage?: string onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void @@ -90,7 +91,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
+
0} fallback={ From 15b8c1454221d8da7a7e62cf6ac56c3ac9c43c72 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:19:50 -0600 Subject: [PATCH 29/70] fix: tauri --- packages/tauri/src-tauri/Cargo.lock | 34 ++++++++++++++--------------- packages/tauri/src-tauri/Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/tauri/src-tauri/Cargo.lock b/packages/tauri/src-tauri/Cargo.lock index f2e77a1e806..57d463355ed 100644 --- a/packages/tauri/src-tauri/Cargo.lock +++ b/packages/tauri/src-tauri/Cargo.lock @@ -2,23 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "OpenCode" -version = "0.0.0" -dependencies = [ - "listeners", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-opener", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-updater", - "tokio", -] - [[package]] name = "adler2" version = "2.0.1" @@ -2517,6 +2500,23 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "opencode-desktop" +version = "0.0.0" +dependencies = [ + "listeners", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-updater", + "tokio", +] + [[package]] name = "option-ext" version = "0.2.0" diff --git a/packages/tauri/src-tauri/Cargo.toml b/packages/tauri/src-tauri/Cargo.toml index 3d7bf654dd9..c6b0e409b0e 100644 --- a/packages/tauri/src-tauri/Cargo.toml +++ b/packages/tauri/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "OpenCode" +name = "opencode-desktop" version = "0.0.0" description = "A Tauri App" authors = ["you"] From 89d51ad5962543978968164e6e08f73444af4cc0 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 10 Dec 2025 23:21:38 +0000 Subject: [PATCH 30/70] compaction: improve compaction prompt (#5348) --- packages/opencode/src/session/compaction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 45bab9ae6e9..f9d1b1c0476 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -174,7 +174,7 @@ export namespace SessionCompaction { content: [ { type: "text", - text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.", + text: "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.", }, ], }, From 56540f83125d8ec3fd6f26ac7edca7471c2aca3f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:30:53 -0600 Subject: [PATCH 31/70] wip(desktop): progress --- packages/desktop/src/components/prompt-input.tsx | 6 ++++++ packages/desktop/src/context/layout.tsx | 12 +++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0672dfc85dd..22f2c1642cc 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -621,6 +621,12 @@ export const PromptInput: Component = (props) => {
)} +
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 1de8550cb1d..5530ad28f2a 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -48,9 +48,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open: undefined as undefined | "provider" | "model", }, }) + const usedColors = new Set() function pickAvailableColor() { - const available = PASTEL_COLORS.filter((c) => !colors().has(c)) + const available = PASTEL_COLORS.filter((c) => !usedColors.has(c)) if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -69,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( function colorize(project: Project & { expanded: boolean }) { if (project.icon?.color) return project const color = pickAvailableColor() + usedColors.add(color) project.icon = { ...project.icon, color } globalSdk.client.project.update({ projectID: project.id, icon: { color } }) return project @@ -76,14 +78,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const enriched = createMemo(() => store.projects.flatMap(enrich)) const list = createMemo(() => enriched().flatMap(colorize)) - const colors = createMemo( - () => - new Set( - list() - .map((p) => p.icon?.color) - .filter(Boolean), - ), - ) async function loadProjectSessions(directory: string) { const [, setStore] = globalSync.child(directory) From 1a1874d8b37714baf8a6e0a0f136aae404cff610 Mon Sep 17 00:00:00 2001 From: Jay V Date: Wed, 10 Dec 2025 18:43:19 -0500 Subject: [PATCH 32/70] docs: desktop --- packages/console/app/src/config.ts | 8 ++++---- packages/console/app/src/routes/index.tsx | 17 +++++------------ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index a058f6829a3..e8a2ed252ba 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/sst/opencode", starsFormatted: { - compact: "35K", - full: "35,000", + compact: "38K", + full: "38,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "350", - commits: "5,000", + contributors: "375", + commits: "5,250", monthlyUsers: "400,000", }, } as const diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index 56f07856224..f46a4e028ef 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -157,15 +157,9 @@ export default function Home() {

What is OpenCode?

-

OpenCode is an open source agent that helps you write and run code directly from the terminal.

+

OpenCode is an open source agent that helps you write code in your terminal, IDE, or desktop.

    -
  • - [*] -
    - Native TUI A responsive, native, themeable terminal UI -
    -
  • [*]
    @@ -199,7 +193,7 @@ export default function Home() {
  • [*]
    - Any editor OpenCode runs in your terminal, pair it with any IDE + Any editor Available as a terminal interface, desktop app, and IDE extension
@@ -651,9 +645,8 @@ export default function Home() {