From 5c7a021e616ceb4157e43029bceddd478847ef2f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 15:07:32 -0600 Subject: [PATCH 01/30] ignore: add bash tests --- packages/opencode/test/tool/bash.test.ts | 419 +++++++++++++++++++++-- 1 file changed, 399 insertions(+), 20 deletions(-) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 0116f47cff..9ef7dfb9d8 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", () => { }, }) }) - - // 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() - // }, - // }) - // }) +}) + +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") + }, + }) + }) + + 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 d5bf02f994f6213e4cd26888607b97fb777705f6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:13:04 -0500 Subject: [PATCH 02/30] 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 f0044607cb..36cebf6aac 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 62459cc288..80c7126057 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 afb34094f6..d380e35768 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 9828c36310ea323c7aa9301e1fa75ae9239ac357 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:14:32 -0500 Subject: [PATCH 03/30] 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 890801611b..58fc8c9cd6 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 510f8d4271565db2ca1d7bceee62d856addde34c 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 04/30] 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 b7d1fabb58..05a47c4eb7 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 9c4bdb4fb3e82fd422f1e1f56715a0d6b17b2994 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 05/30] 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 3ff3abb0ee..0c8fdf6d73 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 bc6bb6f6da..d0a414fee7 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 4450358f83..338e045ef0 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 e7dc04c1c546fa7795e55656204bfdcf73702246 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 07/30] wip(desktop): progress --- .../desktop/src/components/prompt-input.tsx | 20 +- packages/ui/index.html | 14 - packages/ui/src/components/icon.tsx | 191 ++---------- 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, 103 insertions(+), 555 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 824d3da121..fbb643e58b 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 7697a5f962..0000000000 --- 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 8c83b41ce0..97f2e8eabf 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": ``, + "align-right": ``, + "arrow-up": ``, + "bubble-5": ``, + "bullet-list": ``, + "check-small": ``, "chevron-down": ``, "chevron-right": ``, - "arrow-up": ``, - "check-small": ``, - "edit-small-2": ``, - folder: ``, - "pencil-line": ``, "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": ``, 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 6081f08949..0000000000 --- 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 27bcac4dac..0000000000 --- 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 fa76ba9af0..0000000000 --- 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 ab45a3a25f..074859f352 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 338e045ef0..01ccc3fccd 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 54356461fb499c358ccb391b6c287f32d1ae3c8d 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 08/30] 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 1652adb3ee..bb83e7682d 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 65c8b5a81f..4579a06f35 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 0c8fdf6d73..4a17d01bd6 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 267c891f39..1c7cd4f411 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 696f68bf97..83085e0829 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 381c5f6fcc..90c269eea3 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 d5ca922b423ac3ace38545ae48422c4c78cc4577 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 09/30] 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 fbb643e58b..bbd638e446 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 8223a36b9c..58a65b0de8 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 14309a492838f973d71ff85e83a7991d22f61256 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 10/30] 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 bbd638e446..8579647daf 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 83085e0829..206eade0d8 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 90c269eea3..695791aad4 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 8665b508444414c7c26aedb95857c59d0a76d207 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 11/30] 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 | 32 +++++--- packages/ui/src/components/select-dialog.css | 1 - packages/ui/src/components/select-dialog.tsx | 6 +- 10 files changed, 163 insertions(+), 102 deletions(-) diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 8579647daf..97d27ee1e4 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 58fc8c9cd6..3e2b6bf7d8 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 05a47c4eb7..13c4679d69 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 4a17d01bd6..3e00947563 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 57d463355e..f2e77a1e80 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 c6b0e409b0..3d7bf654dd 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 1ff3008eeb..fb5798b085 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 192c7b60ca..f953170280 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -102,23 +102,12 @@ 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; - } - - &[data-size="large"] { - height: 32px; - padding: 0 8px; - - &[data-icon] { - padding: 0 8px 0 6px; - } - - gap: 8px; /* text-12-medium */ font-family: var(--font-family-sans); @@ -129,6 +118,25 @@ letter-spacing: var(--letter-spacing-normal); } + &[data-size="large"] { + height: 32px; + padding: 0 8px; + + &[data-icon] { + padding: 0 12px 0 8px; + } + + gap: 8px; + + /* 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); + } + &:focus { outline: none; } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css index 206eade0d8..cc834f7950 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 695791aad4..b93993ad40 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 610be75b7c9cc0d34483fcff527d3840f7041c77 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 12/30] 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 97d27ee1e4..985dbae8e2 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 13c4679d69..1de8550cb1 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 58a65b0de8..74d3ac364c 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 31004811bc..db2b3af7c3 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 85986c3271..1a11cd5998 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 4aac241e13..205ffd8157 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 3e00947563..2ea6c4ba02 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 15a36b2ff4..1c593ca87e 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 924dcd25c5..d653765a55 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 cc834f7950..f5687ad8ee 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 b93993ad40..86f7232257 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 cc6f9d73f91fda701909de9a1d02e626096bb6e1 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 13/30] 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 0000000000..63d9a2fe1e --- /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 0000000000..3fbeb35f60 --- /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 f5687ad8ee..9759174a6d 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 86f7232257..952ba881f7 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, - }) + const [filter, setFilter] = createSignal("") + let listRef: ListRef | undefined createEffect(() => { - filter() - scrollRef()?.scrollTo(0, 0) - reset() - }) - - 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 074859f352..4c7f6e80ba 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 004cae2fc8b9b204318446f65f7c5406673042a0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:17:27 -0500 Subject: [PATCH 14/30] 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 0ca4d5e6be..a1ff90d269 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 d380e35768..d799325740 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 6b9ce88e01..c72805fe64 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 7c77c36fa290758c76c135dc46caf93df35cb9da Mon Sep 17 00:00:00 2001 From: Github Action Date: Wed, 10 Dec 2025 21:18:57 +0000 Subject: [PATCH 15/30] 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 a9117fa856..ee38c07f5e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-JT8J+Nd2kk0x46BcyotmBbM39tuKOW7VzXfOV3R3sqQ=" + "nodeModules": "sha256-WQMQmqKojxdRtwv6KL9HBaDfwYa4qPn2pvXKqgNM73A=" } From 510371aab27def5e7dd1daad11e71c74d49e6ef1 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 16/30] 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 2ea8cf007d..4664f2a881 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 28ea60a67f..f74f787db8 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 c117e94374a0ebc0be06fbd8deef42d31195ff91 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 16:01:10 -0600 Subject: [PATCH 17/30] 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 4664f2a881..17e5c180a1 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 d3d159244795f26e7c0539162d2fae242b04985d Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 11 Dec 2025 06:23:12 +0800 Subject: [PATCH 18/30] 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 61fe4e5bde..658329fb6e 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 c897bc20c1c6718a9a4a46e63e495df9fcd6f00c 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 19/30] 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 845ac63330..77584f1a53 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 8405966f71..5f9b040d4d 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 c88e0c3fefc7141bc8aec1ff377be8c4d3c89770 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 14:36:11 -0800 Subject: [PATCH 20/30] 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 628235afd0..185c0a5c36 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 be392bd0c5494c7d2aee3b1e38db4070bdd1d12b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 17:06:16 -0600 Subject: [PATCH 21/30] 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 1107ddd6a5..4c501c1e10 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 b99674e23ea6564c87c80d7bebd5276d6a39da28 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 15:12:49 -0800 Subject: [PATCH 22/30] 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 71b99ab0d5..76c7029825 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 5f3d9fefb861bdd9c21539945aecfcc6bae569c8 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 23/30] 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 985dbae8e2..0672dfc85d 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 2ea6c4ba02..10d4cbfda7 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 63d9a2fe1e..38dcb773b5 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 3fbeb35f60..a7f2db9efb 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 1b80d7238e795dc5cbcf88664d9656aabe1487b0 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 24/30] 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 f2e77a1e80..57d463355e 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 3d7bf654dd..c6b0e409b0 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 c97ef9840e79d0f68873bdbf0e0f191a2665d325 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 10 Dec 2025 23:21:38 +0000 Subject: [PATCH 25/30] 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 45bab9ae6e..f9d1b1c047 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 e5fc053e48b26d8a30dc765888e7c990083579f3 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 26/30] 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 0672dfc85d..22f2c1642c 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 1de8550cb1..5530ad28f2 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 e18dd941d26a252a2386c2a8e85ccc3d357eebe7 Mon Sep 17 00:00:00 2001 From: Jay V Date: Wed, 10 Dec 2025 18:43:19 -0500 Subject: [PATCH 27/30] 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 a058f6829a..e8a2ed252b 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 56f0785622..f46a4e028e 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() {
  • - OpenCode is an open source agent that helps you write and run code directly from the terminal. You can - pair OpenCode with any AI model, and because it’s terminal-based you can pair it with your preferred - code editor. + OpenCode is an open source agent that helps you write and run code with any AI model. It's available + as a terminal-based interface, desktop app, or IDE extension.
  • @@ -674,7 +667,7 @@ export default function Home() {
  • - Yes, for now. We are actively working on a desktop app. Join the waitlist for early access. + Not anymore! OpenCode is now available as an app for your desktop.
  • From c76e7e90ff6280791f60b4bed4ae2ae5aceea0ab Mon Sep 17 00:00:00 2001 From: Jay V Date: Wed, 10 Dec 2025 19:05:33 -0500 Subject: [PATCH 28/30] core: reposition OpenCode as open source multi-platform coding agent docs: update main intro page to reflect open source positioning and multi-platform availability --- README.md | 2 +- packages/console/app/src/app.tsx | 2 +- packages/web/astro.config.mjs | 2 +- packages/web/src/content/docs/index.mdx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da5d586648..eb0295c9c8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

    -

    The AI coding agent built for the terminal.

    +

    The open source AI coding agent.

    Discord npm diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index bc94b443e9..8cc98ad4db 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -13,7 +13,7 @@ export default function App() { root={(props) => ( opencode - + {props.children} diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 3c6367c6a6..1e112b1705 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -31,7 +31,7 @@ export default defineConfig({ configSchema(), solidJs(), starlight({ - title: "opencode", + title: "OpenCode", lastUpdated: true, expressiveCode: { themes: ["github-light", "github-dark"] }, social: [ diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx index 74ac958d1a..205f63154a 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/index.mdx @@ -7,7 +7,7 @@ import { Tabs, TabItem } from "@astrojs/starlight/components" import config from "../../../config.mjs" export const console = config.console -[**OpenCode**](/) is an AI coding agent built for the terminal. +[**OpenCode**](/) is an open source AI coding agent. It's available as a terminal-based interface, desktop app, or IDE extension. ![OpenCode TUI with the opencode theme](../../assets/lander/screenshot.png) @@ -17,7 +17,7 @@ Let's get started. #### Prerequisites -To use OpenCode, you'll need: +To use OpenCode in your terminal, you'll need: 1. A modern terminal emulator like: - [WezTerm](https://wezterm.org), cross-platform From 3b534eec300798e30437f37985be637959225c9c Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 19:02:14 -0500 Subject: [PATCH 29/30] fix deploy --- packages/ui/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 20f3e733fc..3ac942cd29 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,7 +17,6 @@ "scripts": { "typecheck": "tsgo --noEmit", "dev": "vite", - "build": "vite build", "generate:tailwind": "bun run script/tailwind.ts" }, "devDependencies": { From daca1c94132d1634780caafeee03fba27270f410 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 19:27:05 -0500 Subject: [PATCH 30/30] desktop: enable zoom hotkeys in Tauri app --- packages/tauri/src-tauri/capabilities/default.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tauri/src-tauri/capabilities/default.json b/packages/tauri/src-tauri/capabilities/default.json index df40065ee8..ef5a207b4e 100644 --- a/packages/tauri/src-tauri/capabilities/default.json +++ b/packages/tauri/src-tauri/capabilities/default.json @@ -7,6 +7,7 @@ "core:default", "opener:default", "core:window:allow-start-dragging", + "core:webview:allow-set-webview-zoom", "shell:default", "updater:default", "dialog:default",