From 59fb3ae606764e8bd3dc8f8d9fc40b952aee8257 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 0116f47cf..9ef7dfb9d 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 9ad828dcd0e2bbcd02eee2700856c20ed118e174 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 f0044607c..36cebf6aa 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 62459cc28..80c712605 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 afb34094f..d380e3576 100644 --- a/packages/tauri/src-tauri/src/lib.rs +++ b/packages/tauri/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - net::SocketAddr, + net::{SocketAddr, TcpListener}, process::Command, sync::{Arc, Mutex}, time::{Duration, Instant}, @@ -18,7 +18,13 @@ fn get_sidecar_port() -> u16 { .map(|s| s.to_string()) .or_else(|| std::env::var("OPENCODE_PORT").ok()) .and_then(|port_str| port_str.parse().ok()) - .unwrap_or(4096) + .unwrap_or_else(|| { + TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind to find free port") + .local_addr() + .expect("Failed to get local address") + .port() + }) } fn find_and_kill_process_on_port(port: u16) -> Result<(), Box> { @@ -60,6 +66,7 @@ fn spawn_sidecar(app: &AppHandle, port: u16) -> CommandChild { .shell() .sidecar("opencode") .unwrap() + .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") .args(["serve", &format!("--port={port}")]) .spawn() .expect("Failed to spawn opencode"); From 8d3eac2347c525039e96a540c29d6bb9cc26cc8f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:14:32 -0500 Subject: [PATCH 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 890801611..58fc8c9cd 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -97,7 +97,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple setGlobalStore("children", directory, { project: "", config: {}, - path: { state: "", config: "", worktree: "", directory: "" }, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, ready: false, agent: [], provider: [], From 67a95c3cc8de5dc8e71f8af8efbaca4e7efdb4d6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:05:27 -0600 Subject: [PATCH 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 b7d1fabb5..05a47c4eb 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -66,7 +66,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open(directory: string) { if (store.projects.find((x) => x.worktree === directory)) return loadProjectSessions(directory) - setStore("projects", (x) => [...x, { worktree: directory, expanded: true }]) + setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) }, close(directory: string) { setStore("projects", (x) => x.filter((x) => x.worktree !== directory)) From a4ec619c74318c499c61c3198a3f82e9262cc7e5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:52:05 -0600 Subject: [PATCH 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 3ff3abb0e..0c8fdf6d7 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 bc6bb6f6d..d0a414fee 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 4450358f8..338e045ef 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -43,6 +43,7 @@ --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; + --radius-xl: 0.625rem; --shadow-xs: 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); From ada40decd14fc18901486382a10b1ec1d0d21f7e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:55:44 -0600 Subject: [PATCH 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 824d3da12..fbb643e58 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 7697a5f96..000000000 --- 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 8c83b41ce..97f2e8eab 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 6081f0894..000000000 --- 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 27bcac4da..000000000 --- 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 fa76ba9af..000000000 --- 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 ab45a3a25..074859f35 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 338e045ef..01ccc3fcc 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -40,6 +40,7 @@ --container-6xl: 72rem; --container-7xl: 80rem; + --radius-xs: 0.125rem; --radius-sm: 0.25rem; --radius-md: 0.375rem; --radius-lg: 0.5rem; From e694d4d8806857fa5035c2953027ffee03e843dc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:23:07 -0600 Subject: [PATCH 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 1652adb3e..bb83e7682 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 65c8b5a81..4579a06f3 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 0c8fdf6d7..4a17d01bd 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 267c891f3..1c7cd4f41 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 696f68bf9..83085e082 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 381c5f6fc..90c269eea 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -153,7 +153,7 @@ export function SelectDialog(props: SelectDialogProps) { }} > {others.children(item)} - + )} From f20d6e855556693e33cddd51c837263c8846694d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:27:30 -0600 Subject: [PATCH 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 fbb643e58..bbd638e44 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 8223a36b9..58a65b0de 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -25,6 +25,7 @@ export type View = LocalFile["view"] export type LocalModel = Omit & { provider: Provider + latest?: boolean } export type ModelKey = { providerID: string; modelID: string } @@ -114,7 +115,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const list = createMemo(() => - sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)), + sync.data.provider.flatMap((p) => + Object.values(p.models).map( + (m) => + ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + provider: p, + latest: m.name.includes("(latest)"), + }) as LocalModel, + ), + ), ) const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) From 804ad5897f17cd5f002fbd0c124d5301205efcfb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:46:10 -0600 Subject: [PATCH 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 bbd638e44..8579647da 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 83085e082..206eade0d 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 90c269eea..695791aad 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,4 +1,4 @@ -import { createEffect, Show, For, type JSX, splitProps } from "solid-js" +import { createEffect, Show, For, type JSX, splitProps, createSignal } from "solid-js" import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Dialog, DialogProps } from "./dialog" @@ -21,7 +21,7 @@ export function SelectDialog(props: SelectDialogProps) { const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) let closeButton!: HTMLButtonElement let inputRef: HTMLInputElement | undefined - let scrollRef: HTMLDivElement | undefined + let [scrollRef, setScrollRef] = createSignal(undefined) const [store, setStore] = createStore({ mouseActive: false, }) @@ -38,18 +38,28 @@ export function SelectDialog(props: SelectDialogProps) { createEffect(() => { filter() - scrollRef?.scrollTo(0, 0) + scrollRef()?.scrollTo(0, 0) reset() }) + createEffect(() => { + if (!scrollRef()) return + if (!others.current) return + const key = others.key(others.current) + requestAnimationFrame(() => { + const element = scrollRef()!.querySelector(`[data-key="${key}"]`) + element?.scrollIntoView({ block: "center" }) + }) + }) + createEffect(() => { const all = flat() if (store.mouseActive || all.length === 0) return if (active() === others.key(all[0])) { - scrollRef?.scrollTo(0, 0) + scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) @@ -120,7 +130,7 @@ export function SelectDialog(props: SelectDialogProps) { />
- + 0} fallback={ From 91d743ef9a5c346fe17bb857db68dca92a6e9ba1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:48:08 -0600 Subject: [PATCH 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 8579647da..97d27ee1e 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 58fc8c9cd..3e2b6bf7d 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 05a47c4eb..13c4679d6 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 4a17d01bd..3e0094756 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 57d463355..f2e77a1e8 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 c6b0e409b..3d7bf654d 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 1ff3008ee..fb5798b08 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 192c7b60c..f95317028 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 206eade0d..cc834f795 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 695791aad..b93993ad4 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -15,6 +15,7 @@ interface SelectDialogProps children: (item: T) => JSX.Element onSelect?: (value: T | undefined) => void onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void + actions?: JSX.Element } export function SelectDialog(props: SelectDialogProps) { @@ -98,7 +99,8 @@ export function SelectDialog(props: SelectDialogProps) { {others.title} - + {others.actions} +
@@ -136,7 +138,7 @@ export function SelectDialog(props: SelectDialogProps) { fallback={
- {props.emptyMessage ?? "No search results"} for{" "} + {props.emptyMessage ?? "No results"} for{" "} "{filter()}"
From 190fa4c87aa2b3f954a419f716add1fc29e4011e Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:48:08 -0600 Subject: [PATCH 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 97d27ee1e..985dbae8e 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 13c4679d6..1de8550cb 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 58a65b0de..74d3ac364 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 31004811b..db2b3af7c 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 85986c327..1a11cd599 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 4aac241e1..205ffd815 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 3e0094756..2ea6c4ba0 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 15a36b2ff..1c593ca87 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 924dcd25c..d653765a5 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 cc834f795..f5687ad8e 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 b93993ad4..86f723225 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -2,7 +2,7 @@ import { createEffect, Show, For, type JSX, splitProps, createSignal } from "sol import { createStore } from "solid-js/store" import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" +import { Icon, IconProps } from "./icon" import { Input } from "./input" import { IconButton } from "./icon-button" @@ -16,6 +16,7 @@ interface SelectDialogProps onSelect?: (value: T | undefined) => void onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void actions?: JSX.Element + activeIcon?: IconProps["name"] } export function SelectDialog(props: SelectDialogProps) { @@ -165,7 +166,12 @@ export function SelectDialog(props: SelectDialogProps) { }} > {others.children(item)} - + + + + + {(icon) => } + )} From 58e66dd3d1dfd975195dac916fb4b23093404243 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:16:57 -0600 Subject: [PATCH 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 000000000..63d9a2fe1 --- /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 000000000..3fbeb35f6 --- /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 f5687ad8e..9759174a6 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 86f723225..952ba881f 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 074859f35..4c7f6e80b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -22,6 +22,7 @@ @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/input.css" layer(components); +@import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); From 86f7cc17ae81fd36f3f2fce22439773002f3fd3a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 10 Dec 2025 16:17:27 -0500 Subject: [PATCH 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 0ca4d5e6b..a1ff90d26 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 d380e3576..d79932574 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 6b9ce88e0..c72805fe6 100644 --- a/packages/tauri/src/index.tsx +++ b/packages/tauri/src/index.tsx @@ -47,12 +47,6 @@ const platform: Platform = { }, } -declare global { - interface Window { - __OPENCODE__?: { updaterEnabled?: boolean } - } -} - render(() => { onMount(() => { if (window.__OPENCODE__?.updaterEnabled) runUpdater() From e060f968f5f87b9e176ce7af41fd82f015ec54b0 Mon Sep 17 00:00:00 2001 From: Github Action Date: Wed, 10 Dec 2025 21:18:57 +0000 Subject: [PATCH 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 a9117fa85..ee38c07f5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,3 +1,3 @@ { - "nodeModules": "sha256-JT8J+Nd2kk0x46BcyotmBbM39tuKOW7VzXfOV3R3sqQ=" + "nodeModules": "sha256-WQMQmqKojxdRtwv6KL9HBaDfwYa4qPn2pvXKqgNM73A=" } From 7435d94f85364654dda80c7b41d9c2379ebad640 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Wed, 10 Dec 2025 22:55:15 +0100 Subject: [PATCH 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 2ea8cf007..4664f2a88 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 28ea60a67..f74f787db 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -24,6 +24,7 @@ import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" +import type { Path } from "@opencode-ai/sdk" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -62,6 +63,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } formatter: FormatterStatus[] vcs: VcsInfo | undefined + path: Path }>({ provider_next: { all: [], @@ -86,6 +88,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp: {}, formatter: [], vcs: undefined, + path: { state: "", config: "", worktree: "", directory: "" }, }) const sdk = useSDK() @@ -286,6 +289,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sdk.client.session.status().then((x) => setStore("session_status", x.data!)), sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})), sdk.client.vcs.get().then((x) => setStore("vcs", x.data)), + sdk.client.path.get().then((x) => setStore("path", x.data!)), ]).then(() => { setStore("status", "complete") }) From 7d82f1769cbde45384467308106099610fb7810a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 16:01:10 -0600 Subject: [PATCH 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 4664f2a88..17e5c180a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/directory.ts +++ b/packages/opencode/src/cli/cmd/tui/context/directory.ts @@ -5,7 +5,7 @@ import { Global } from "@/global" export function useDirectory() { const sync = useSync() return createMemo(() => { - const directory = sync.data.path.directory ?? process.cwd() + const directory = sync.data.path.directory || process.cwd() const result = directory.replace(Global.Path.home, "~") if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch return result From e46080aa8c34ef3132d76b412cc6e750a2f16b32 Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 11 Dec 2025 06:23:12 +0800 Subject: [PATCH 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 61fe4e5bd..658329fb6 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -10,6 +10,154 @@ import { Config } from "../../config/config" import { Global } from "../../global" import { Plugin } from "../../plugin" import { Instance } from "../../project/instance" +import type { Hooks } from "@opencode-ai/plugin" + +type PluginAuth = NonNullable + +/** + * Handle plugin-based authentication flow. + * Returns true if auth was handled, false if it should fall through to default handling. + */ +async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string): Promise { + let index = 0 + if (plugin.auth.methods.length > 1) { + const method = await prompts.select({ + message: "Login method", + options: [ + ...plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index.toString(), + })), + ], + }) + if (prompts.isCancel(method)) throw new UI.CancelledError() + index = parseInt(method) + } + const method = plugin.auth.methods[index] + + // Handle prompts for all auth types + await new Promise((resolve) => setTimeout(resolve, 10)) + const inputs: Record = {} + if (method.prompts) { + for (const prompt of method.prompts) { + if (prompt.condition && !prompt.condition(inputs)) { + continue + } + if (prompt.type === "select") { + const value = await prompts.select({ + message: prompt.message, + options: prompt.options, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } else { + const value = await prompts.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + if (prompts.isCancel(value)) throw new UI.CancelledError() + inputs[prompt.key] = value + } + } + } + + if (method.type === "oauth") { + const authorize = await method.authorize(inputs) + + if (authorize.url) { + prompts.log.info("Go to: " + authorize.url) + } + + if (authorize.method === "auto") { + if (authorize.instructions) { + prompts.log.info(authorize.instructions) + } + const spinner = prompts.spinner() + spinner.start("Waiting for authorization...") + const result = await authorize.callback() + if (result.type === "failed") { + spinner.stop("Failed to authorize", 1) + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + spinner.stop("Login successful") + } + } + + if (authorize.method === "code") { + const code = await prompts.text({ + message: "Paste the authorization code here: ", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(code)) throw new UI.CancelledError() + const result = await authorize.callback(code) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + if ("refresh" in result) { + const { type: _, provider: __, refresh, access, expires, ...extraFields } = result + await Auth.set(saveProvider, { + type: "oauth", + refresh, + access, + expires, + ...extraFields, + }) + } + if ("key" in result) { + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + } + prompts.log.success("Login successful") + } + } + + prompts.outro("Done") + return true + } + + if (method.type === "api") { + if (method.authorize) { + const result = await method.authorize(inputs) + if (result.type === "failed") { + prompts.log.error("Failed to authorize") + } + if (result.type === "success") { + const saveProvider = result.provider ?? provider + await Auth.set(saveProvider, { + type: "api", + key: result.key, + }) + prompts.log.success("Login successful") + } + prompts.outro("Done") + return true + } + } + + return false +} export const AuthCommand = cmd({ command: "auth", @@ -160,142 +308,8 @@ export const AuthLoginCommand = cmd({ const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) if (plugin && plugin.auth) { - let index = 0 - if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: [ - ...plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - ], - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } - const method = plugin.auth.methods[index] - - // Handle prompts for all auth types - await new Promise((resolve) => setTimeout(resolve, 10)) - const inputs: Record = {} - if (method.prompts) { - for (const prompt of method.prompts) { - if (prompt.condition && !prompt.condition(inputs)) { - continue - } - if (prompt.type === "select") { - const value = await prompts.select({ - message: prompt.message, - options: prompt.options, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } - } - } - - if (method.type === "oauth") { - const authorize = await method.authorize(inputs) - - if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) - } - - if (authorize.method === "auto") { - if (authorize.instructions) { - prompts.log.info(authorize.instructions) - } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() - if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) - } - if ("key" in result) { - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - } - spinner.stop("Login successful") - } - } - - if (authorize.method === "code") { - const code = await prompts.text({ - message: "Paste the authorization code here: ", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - if ("refresh" in result) { - const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await Auth.set(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) - } - if ("key" in result) { - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - } - prompts.log.success("Login successful") - } - } - - prompts.outro("Done") - return - } - - if (method.type === "api") { - if (method.authorize) { - const result = await method.authorize(inputs) - if (result.type === "failed") { - prompts.log.error("Failed to authorize") - } - if (result.type === "success") { - const saveProvider = result.provider ?? provider - await Auth.set(saveProvider, { - type: "api", - key: result.key, - }) - prompts.log.success("Login successful") - } - prompts.outro("Done") - return - } - } + const handled = await handlePluginAuth({ auth: plugin.auth }, provider) + if (handled) return } if (provider === "other") { @@ -306,6 +320,14 @@ export const AuthLoginCommand = cmd({ if (prompts.isCancel(provider)) throw new UI.CancelledError() provider = provider.replace(/^@ai-sdk\//, "") if (prompts.isCancel(provider)) throw new UI.CancelledError() + + // Check if a plugin provides auth for this custom provider + const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider)) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider) + if (handled) return + } + prompts.log.warn( `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) From 72eb004057a08283f734c7543c08f399f8ba881b Mon Sep 17 00:00:00 2001 From: Hammad Shami <46585994+H2Shami@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:23:52 -0800 Subject: [PATCH 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 845ac6333..77584f1a5 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 8405966f7..5f9b040d4 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -568,6 +568,119 @@ The `global` region improves availability and reduces errors at no extra cost. U --- +### Helicone + +[Helicone](https://helicone.ai) is an LLM observability platform that provides logging, monitoring, and analytics for your AI applications. The Helicone AI Gateway routes your requests to the appropriate provider automatically based on the model. + +1. Head over to [Helicone](https://helicone.ai), create an account, and generate an API key from your dashboard. + +2. Run the `/connect` command and search for **Helicone**. + + ```txt + /connect + ``` + +3. Enter your Helicone API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +For more providers and advanced features like caching and rate limiting, check the [Helicone documentation](https://docs.helicone.ai). + +#### Optional Configs + +In the event you see a feature or model from Helicone that isn't configured automatically through opencode, you can always configure it yourself. + +Here's [Helicone's Model Directory](https://helicone.ai/models), you'll need this to grab the IDs of the models you want to add. + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + }, + "models": { + "gpt-4o": { + // Model ID (from Helicone's model directory page) + "name": "GPT-4o", // Your own custom name for the model + }, + "claude-sonnet-4-20250514": { + "name": "Claude Sonnet 4", + }, + }, + }, + }, +} +``` + +#### Custom Headers + +Helicone supports custom headers for features like caching, user tracking, and session management. Add them to your provider config using `options.headers`: + +```jsonc title="~/.config/opencode/opencode.jsonc" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "helicone": { + "npm": "@ai-sdk/openai-compatible", + "name": "Helicone", + "options": { + "baseURL": "https://ai-gateway.helicone.ai", + "headers": { + "Helicone-Cache-Enabled": "true", + "Helicone-User-Id": "opencode", + }, + }, + }, + }, +} +``` + +##### Session tracking + +Helicone's [Sessions](https://docs.helicone.ai/features/sessions) feature lets you group related LLM requests together. Use the [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) plugin to automatically log each OpenCode conversation as a session in Helicone. + +```bash +npm install -g opencode-helicone-session +``` + +Add it to your config. + +```json title="opencode.json" +{ + "plugin": ["opencode-helicone-session"] +} +``` + +The plugin injects `Helicone-Session-Id` and `Helicone-Session-Name` headers into your requests. In Helicone's Sessions page, you'll see each OpenCode conversation listed as a separate session. + +##### Common Helicone headers + +| Header | Description | +| -------------------------- | ------------------------------------------------------------- | +| `Helicone-Cache-Enabled` | Enable response caching (`true`/`false`) | +| `Helicone-User-Id` | Track metrics by user | +| `Helicone-Property-[Name]` | Add custom properties (e.g., `Helicone-Property-Environment`) | +| `Helicone-Prompt-Id` | Associate requests with prompt versions | + +See the [Helicone Header Directory](https://docs.helicone.ai/helicone-headers/header-directory) for all available headers. + +--- + ### llama.cpp You can configure opencode to use local models through [llama.cpp's](https://github.com/ggml-org/llama.cpp) llama-server utility From b274371dbb2b6853636a063907371418f7cbae46 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 14:36:11 -0800 Subject: [PATCH 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 628235afd..185c0a5c3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1503,11 +1503,15 @@ ToolRegistry.register({ - {(task) => ( - - ∟ {Locale.titlecase(task.tool)} {task.state.status === "completed" ? task.state.title : ""} - - )} + {(task, index) => { + const summary = props.metadata.summary ?? [] + return ( + + {index() === summary.length - 1 ? "└" : "├"} {Locale.titlecase(task.tool)}{" "} + {task.state.status === "completed" ? task.state.title : ""} + + ) + }} From e36c3492221cf8c225bddb6a74431254de8d54a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 10 Dec 2025 17:06:16 -0600 Subject: [PATCH 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 1107ddd6a..4c501c1e1 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -186,7 +186,7 @@ function App() { // Truncate title to 40 chars max const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`oc | ${title}`) + renderer.setTerminalTitle(`OC | ${title}`) } }) From cbb591eb7dfe8e27298945f10e5d6cfff4405630 Mon Sep 17 00:00:00 2001 From: Christian Stewart Date: Wed, 10 Dec 2025 15:12:49 -0800 Subject: [PATCH 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 71b99ab0d..76c702982 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -338,6 +338,7 @@ export namespace SessionPrompt { }, }, })) as MessageV2.ToolPart + let executionError: Error | undefined const result = await taskTool .execute( { @@ -362,7 +363,11 @@ export namespace SessionPrompt { }, }, ) - .catch(() => {}) + .catch((error) => { + executionError = error + log.error("subtask execution failed", { error, agent: task.agent, description: task.description }) + return undefined + }) assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) @@ -388,7 +393,7 @@ export namespace SessionPrompt { ...part, state: { status: "error", - error: "Tool execution failed", + error: executionError ? `Tool execution failed: ${executionError.message}` : "Tool execution failed", time: { start: part.state.status === "running" ? part.state.time.start : Date.now(), end: Date.now(), From 85cfa226c34e41660ddfdcb04543af2e494ae168 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:17:34 -0600 Subject: [PATCH 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 985dbae8e..0672dfc85 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 2ea6c4ba0..10d4cbfda 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 63d9a2fe1..38dcb773b 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 3fbeb35f6..a7f2db9ef 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -4,6 +4,7 @@ import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { Icon, IconProps } from "./icon" export interface ListProps extends FilteredListProps { + class?: string children: (item: T) => JSX.Element emptyMessage?: string onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void @@ -90,7 +91,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) }) return ( -
+
0} fallback={ From 15b8c1454221d8da7a7e62cf6ac56c3ac9c43c72 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:19:50 -0600 Subject: [PATCH 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 f2e77a1e8..57d463355 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 3d7bf654d..c6b0e409b 100644 --- a/packages/tauri/src-tauri/Cargo.toml +++ b/packages/tauri/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "OpenCode" +name = "opencode-desktop" version = "0.0.0" description = "A Tauri App" authors = ["you"] From 89d51ad5962543978968164e6e08f73444af4cc0 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 10 Dec 2025 23:21:38 +0000 Subject: [PATCH 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 45bab9ae6..f9d1b1c04 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -174,7 +174,7 @@ export namespace SessionCompaction { content: [ { type: "text", - text: "Summarize our conversation above. This summary will be the only context available when the conversation continues, so preserve critical information including: what was accomplished, current work in progress, files involved, next steps, and any key user requests or constraints. Be concise but detailed enough that work can continue seamlessly.", + text: "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation.", }, ], }, From 56540f83125d8ec3fd6f26ac7edca7471c2aca3f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:30:53 -0600 Subject: [PATCH 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 0672dfc85..22f2c1642 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 1de8550cb..5530ad28f 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -48,9 +48,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open: undefined as undefined | "provider" | "model", }, }) + const usedColors = new Set() function pickAvailableColor() { - const available = PASTEL_COLORS.filter((c) => !colors().has(c)) + const available = PASTEL_COLORS.filter((c) => !usedColors.has(c)) if (available.length === 0) return PASTEL_COLORS[Math.floor(Math.random() * PASTEL_COLORS.length)] return available[Math.floor(Math.random() * available.length)] } @@ -69,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( function colorize(project: Project & { expanded: boolean }) { if (project.icon?.color) return project const color = pickAvailableColor() + usedColors.add(color) project.icon = { ...project.icon, color } globalSdk.client.project.update({ projectID: project.id, icon: { color } }) return project @@ -76,14 +78,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const enriched = createMemo(() => store.projects.flatMap(enrich)) const list = createMemo(() => enriched().flatMap(colorize)) - const colors = createMemo( - () => - new Set( - list() - .map((p) => p.icon?.color) - .filter(Boolean), - ), - ) async function loadProjectSessions(directory: string) { const [, setStore] = globalSync.child(directory) From 1a1874d8b37714baf8a6e0a0f136aae404cff610 Mon Sep 17 00:00:00 2001 From: Jay V Date: Wed, 10 Dec 2025 18:43:19 -0500 Subject: [PATCH 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 a058f6829..e8a2ed252 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 56f078562..f46a4e028 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 92fa66d76f458d51efa181c0e7b2c02b238e6ebd 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 da5d58664..eb0295c9c 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 bc94b443e..8cc98ad4d 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 3c6367c6a..1e112b170 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 74ac958d1..205f63154 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 13611176b0dbe21894fbd57c0fd2e2c2c02eacb6 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 20f3e733f..3ac942cd2 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 fadeed1fa4caacf34054b231784513a841745766 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 df40065ee..ef5a207b4 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",