summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 20:23:06 +0000
committerDavid Hill <[email protected]>2025-12-12 20:23:06 +0000
commit9b77246246ab0d81aaee23514c343ccb93c36847 (patch)
tree09749dd9d91275b7fec4035fafbefc9e4cd0cb6c
parentcf3bc1e0a6324a01ae10a2849476d7dc07e4876b (diff)
parentd51c6ca39f382ef616157680ca7366b6ee58713e (diff)
downloadopencode-9b77246246ab0d81aaee23514c343ccb93c36847.tar.gz
opencode-9b77246246ab0d81aaee23514c343ccb93c36847.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
-rw-r--r--STATS.md1
-rw-r--r--bun.lock6
-rw-r--r--nix/hashes.json2
-rw-r--r--packages/console/app/src/routes/download/index.tsx9
-rw-r--r--packages/desktop/index.html4
-rw-r--r--packages/desktop/package.json1
-rw-r--r--packages/desktop/src/app.tsx41
-rw-r--r--packages/desktop/src/components/header.tsx113
-rw-r--r--packages/desktop/src/context/global-sync.tsx78
-rw-r--r--packages/desktop/src/context/layout.tsx24
-rw-r--r--packages/desktop/src/context/notification.tsx106
-rw-r--r--packages/desktop/src/context/sync.tsx9
-rw-r--r--packages/desktop/src/pages/home.tsx5
-rw-r--r--packages/desktop/src/pages/layout.tsx307
-rw-r--r--packages/desktop/src/pages/session.tsx2
-rw-r--r--packages/opencode/src/acp/agent.ts2
-rw-r--r--packages/opencode/src/cli/cmd/tui/util/clipboard.ts2
-rw-r--r--packages/opencode/src/provider/transform.ts2
-rw-r--r--packages/tauri/index.html4
-rw-r--r--packages/tauri/package.json1
-rw-r--r--packages/tauri/src-tauri/Cargo.lock84
-rw-r--r--packages/tauri/src-tauri/Cargo.toml1
-rw-r--r--packages/tauri/src-tauri/capabilities/default.json3
-rw-r--r--packages/tauri/src-tauri/src/lib.rs6
-rw-r--r--packages/tauri/src/index.tsx4
-rw-r--r--packages/ui/package.json3
-rw-r--r--packages/ui/src/assets/audio/staplebops-01.aacbin0 -> 5573 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-02.aacbin0 -> 5945 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-03.aacbin0 -> 9660 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-04.aacbin0 -> 5202 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-05.aacbin0 -> 3716 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-06.aacbin0 -> 6316 bytes
-rw-r--r--packages/ui/src/assets/audio/staplebops-07.aacbin0 -> 29351 bytes
-rw-r--r--packages/ui/src/components/icon.tsx3
-rw-r--r--packages/ui/src/components/toast.css14
-rw-r--r--packages/web/src/content/docs/zen.mdx2
36 files changed, 602 insertions, 237 deletions
diff --git a/STATS.md b/STATS.md
index 67f236ebe..8a3e0d553 100644
--- a/STATS.md
+++ b/STATS.md
@@ -167,3 +167,4 @@
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
+| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
diff --git a/bun.lock b/bun.lock
index eba116719..9d5819b15 100644
--- a/bun.lock
+++ b/bun.lock
@@ -131,6 +131,7 @@
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
+ "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
@@ -355,6 +356,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2",
+ "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-shell": "~2",
"@tauri-apps/plugin-store": "~2",
@@ -1548,6 +1550,8 @@
"@solid-primitives/active-element": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
+ "@solid-primitives/audio": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
+
"@solid-primitives/event-bus": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
"@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],
@@ -1660,6 +1664,8 @@
"@tauri-apps/plugin-opener": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
+ "@tauri-apps/plugin-os": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
+
"@tauri-apps/plugin-process": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
"@tauri-apps/plugin-shell": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index 18d4621ed..b64021947 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,3 @@
{
- "nodeModules": "sha256-b6AEbARiEcI/Pu1g0LbRfH1Oo5rClncW44Ug0d4oP0w="
+ "nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck="
}
diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx
index 2616b7ea1..86f266afc 100644
--- a/packages/console/app/src/routes/download/index.tsx
+++ b/packages/console/app/src/routes/download/index.tsx
@@ -10,7 +10,14 @@ import { Legal } from "~/component/legal"
import { config } from "~/config"
const getLatestRelease = query(async () => {
- const response = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
+ "use server"
+ const response = await fetch("https://api.github.com/repos/sst/opencode/releases/latest", {
+ headers: {
+ "User-Agent":
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
+ },
+ })
+
if (!response.ok) return null
const data = await response.json()
return data.tag_name as string
diff --git a/packages/desktop/index.html b/packages/desktop/index.html
index b9d3e5351..9803517a0 100644
--- a/packages/desktop/index.html
+++ b/packages/desktop/index.html
@@ -14,7 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
- <body class="antialiased overscroll-none select-none text-12-regular">
+ <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
@@ -22,7 +22,7 @@
})()
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
- <div id="root"></div>
+ <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 1d12a9cb9..a2b995a4a 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -35,6 +35,7 @@
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
+ "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index a1ff90d26..bf9dfd3b7 100644
--- a/packages/desktop/src/app.tsx
+++ b/packages/desktop/src/app.tsx
@@ -14,6 +14,7 @@ import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { Show } from "solid-js"
+import { NotificationProvider } from "./context/notification"
declare global {
interface Window {
@@ -37,25 +38,27 @@ export function App() {
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
- <MetaProvider>
- <Font />
- <Router root={Layout}>
- <Route path="/" component={Home} />
- <Route path="/:dir" component={DirectoryLayout}>
- <Route path="/" component={() => <Navigate href="session" />} />
- <Route
- path="/session/:id?"
- component={(p) => (
- <Show when={p.params.id || true} keyed>
- <SessionProvider>
- <Session />
- </SessionProvider>
- </Show>
- )}
- />
- </Route>
- </Router>
- </MetaProvider>
+ <NotificationProvider>
+ <MetaProvider>
+ <Font />
+ <Router root={Layout}>
+ <Route path="/" component={Home} />
+ <Route path="/:dir" component={DirectoryLayout}>
+ <Route path="/" component={() => <Navigate href="session" />} />
+ <Route
+ path="/session/:id?"
+ component={(p) => (
+ <Show when={p.params.id || true} keyed>
+ <SessionProvider>
+ <Session />
+ </SessionProvider>
+ </Show>
+ )}
+ />
+ </Route>
+ </Router>
+ </MetaProvider>
+ </NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx
new file mode 100644
index 000000000..cc4d01816
--- /dev/null
+++ b/packages/desktop/src/components/header.tsx
@@ -0,0 +1,113 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { useLayout } from "@/context/layout"
+import { Session } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Select } from "@opencode-ai/ui/select"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { A, useParams } from "@solidjs/router"
+import { createMemo, Show } from "solid-js"
+
+export function Header(props: {
+ navigateToProject: (directory: string) => void
+ navigateToSession: (session: Session | undefined) => void
+}) {
+ const globalSync = useGlobalSync()
+ const layout = useLayout()
+ const params = useParams()
+ const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+ const store = createMemo(() => globalSync.child(currentDirectory())[0])
+ const sessions = createMemo(() => store().session ?? [])
+ const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+
+ return (
+ <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+ <A
+ href="/"
+ classList={{
+ "w-12 shrink-0 px-4 py-3.5": true,
+ "flex items-center justify-start self-stretch": true,
+ "border-r border-border-weak-base": true,
+ }}
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+ data-tauri-drag-region
+ >
+ <Mark class="shrink-0" />
+ </A>
+ <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
+ <Show when={params.dir && layout.projects.list().length > 0}>
+ <div class="flex items-center gap-3">
+ <div class="flex items-center gap-2">
+ <Select
+ options={layout.projects.list().map((project) => project.worktree)}
+ current={currentDirectory()}
+ label={(x) => getFilename(x)}
+ onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+ class="text-14-regular text-text-base"
+ variant="ghost"
+ >
+ {/* @ts-ignore */}
+ {(i) => (
+ <div class="flex items-center gap-2">
+ <Icon name="folder" size="small" />
+ <div class="text-text-strong">{getFilename(i)}</div>
+ </div>
+ )}
+ </Select>
+ <div class="text-text-weaker">/</div>
+ <Select
+ options={sessions()}
+ current={currentSession()}
+ placeholder="New session"
+ label={(x) => x.title}
+ value={(x) => x.id}
+ onSelect={props.navigateToSession}
+ class="text-14-regular text-text-base max-w-md"
+ variant="ghost"
+ />
+ </div>
+ <Show when={currentSession()}>
+ <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+ New session
+ </Button>
+ </Show>
+ </div>
+ <div class="flex items-center gap-4">
+ <Tooltip
+ class="shrink-0"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle terminal</span>
+ <span class="text-icon-base text-12-medium">Ctrl `</span>
+ </div>
+ }
+ >
+ <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+ class="group-hover/terminal-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-bottom-partial"
+ class="hidden group-hover/terminal-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+ class="hidden group-active/terminal-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </Tooltip>
+ </div>
+ </Show>
+ </div>
+ </header>
+ )
+}
diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx
index 2a24a845c..8151a2c6f 100644
--- a/packages/desktop/src/context/global-sync.tsx
+++ b/packages/desktop/src/context/global-sync.tsx
@@ -55,45 +55,20 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
const globalSDK = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
+ path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({
ready: false,
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
children: {},
})
- async function bootstrapInstance(directory: string) {
- const [store, setStore] = child(directory)
- const sdk = createOpencodeClient({
- baseUrl: globalSDK.url,
- directory,
- })
- const load = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
- path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
- agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
- session: () =>
- sdk.session.list().then((x) => {
- const sessions = (x.data ?? [])
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- .slice(0, store.limit)
- setStore("session", sessions)
- }),
- status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
- config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
- changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
- node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
- }
- await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
- }
-
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
@@ -120,6 +95,38 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
return children[directory]
}
+ async function loadSessions(directory: string) {
+ globalSDK.client.session.list({ directory }).then((x) => {
+ const sessions = (x.data ?? [])
+ .slice()
+ .filter((s) => !s.time.archived)
+ .sort((a, b) => a.id.localeCompare(b.id))
+ .slice(0, 5)
+ const [, setStore] = child(directory)
+ setStore("session", sessions)
+ })
+ }
+
+ async function bootstrapInstance(directory: string) {
+ const [, setStore] = child(directory)
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ directory,
+ })
+ const load = {
+ project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
+ provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
+ path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
+ agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+ session: () => loadSessions(directory),
+ status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
+ config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
+ changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
+ node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
+ }
+ await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+ }
+
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -156,6 +163,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+ if (event.properties.info.time.archived) {
+ if (result.found) {
+ setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ break
+ }
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
@@ -224,6 +242,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
async function bootstrap() {
return Promise.all([
+ globalSDK.client.path.get().then((x) => {
+ setGlobalStore("path", x.data!)
+ }),
globalSDK.client.project.list().then(async (x) => {
setGlobalStore(
"project",
@@ -252,6 +273,9 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
},
child,
bootstrap,
+ project: {
+ loadSessions,
+ },
}
},
})
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 9cafdce96..3d5cad761 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -7,15 +7,10 @@ import { useGlobalSDK } from "./global-sdk"
import { Project } from "@opencode-ai/sdk/v2"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
-
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
-export function isAvatarColorKey(value: string): value is AvatarColorKey {
- return AVATAR_COLOR_KEYS.includes(value as AvatarColorKey)
-}
-
export function getAvatarColors(key?: string) {
- if (key && isAvatarColorKey(key)) {
+ if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
return {
background: `var(--avatar-background-${key})`,
foreground: `var(--avatar-text-${key})`,
@@ -50,7 +45,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
- name: "default-layout.v7",
+ name: "layout.v1",
},
)
const [ephemeral, setEphemeral] = createStore<{
@@ -97,21 +92,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const enriched = createMemo(() => store.projects.flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
- async function loadProjectSessions(directory: string) {
- const [, setStore] = globalSync.child(directory)
- globalSdk.client.session.list({ directory }).then((x) => {
- const sessions = (x.data ?? [])
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- .slice(0, 5)
- setStore("session", sessions)
- })
- }
-
onMount(() => {
Promise.all(
store.projects.map((project) => {
- return loadProjectSessions(project.worktree)
+ return globalSync.project.loadSessions(project.worktree)
}),
)
})
@@ -121,7 +105,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
list,
open(directory: string) {
if (store.projects.find((x) => x.worktree === directory)) return
- loadProjectSessions(directory)
+ globalSync.project.loadSessions(directory)
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
},
close(directory: string) {
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
new file mode 100644
index 000000000..744e4fdf3
--- /dev/null
+++ b/packages/desktop/src/context/notification.tsx
@@ -0,0 +1,106 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { makePersisted } from "@solid-primitives/storage"
+import { useGlobalSDK } from "./global-sdk"
+import { EventSessionError } from "@opencode-ai/sdk/v2"
+import { makeAudioPlayer } from "@solid-primitives/audio"
+import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
+
+type NotificationBase = {
+ directory?: string
+ session?: string
+ metadata?: any
+ time: number
+ viewed: boolean
+}
+
+type TurnCompleteNotification = NotificationBase & {
+ type: "turn-complete"
+}
+
+type ErrorNotification = NotificationBase & {
+ type: "error"
+ error: EventSessionError["properties"]["error"]
+}
+
+export type Notification = TurnCompleteNotification | ErrorNotification
+
+export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
+ name: "Notification",
+ init: () => {
+ const idlePlayer = makeAudioPlayer(idleSound)
+ const globalSDK = useGlobalSDK()
+
+ const [store, setStore] = makePersisted(
+ createStore({
+ list: [] as Notification[],
+ }),
+ {
+ name: "notification.v1",
+ },
+ )
+
+ // onMount(() => {
+ // const daysToKeep = 7
+ // // setStore("list", (n) => n.filter((n) => !n.viewed && n.time + 1000 * 60 * 60 * 24 * daysToKeep < Date.now()))
+ // })
+
+ globalSDK.event.listen((e) => {
+ const directory = e.name
+ const event = e.details
+ const base = {
+ directory,
+ time: Date.now(),
+ viewed: false,
+ }
+ switch (event.type) {
+ case "session.idle": {
+ idlePlayer.play()
+ const session = event.properties.sessionID
+ setStore("list", store.list.length, {
+ ...base,
+ type: "turn-complete",
+ session,
+ })
+ break
+ }
+ case "session.error": {
+ const session = event.properties.sessionID ?? "global"
+ // errorPlayer.play()
+ setStore("list", store.list.length, {
+ ...base,
+ type: "error",
+ session,
+ error: "error" in event.properties ? event.properties.error : undefined,
+ })
+ break
+ }
+ }
+ })
+
+ return {
+ session: {
+ all(session: string) {
+ return store.list.filter((n) => n.session === session)
+ },
+ unseen(session: string) {
+ return store.list.filter((n) => n.session === session && !n.viewed)
+ },
+ markViewed(session: string) {
+ setStore("list", (n) => n.session === session, "viewed", true)
+ },
+ },
+ project: {
+ all(directory: string) {
+ return store.list.filter((n) => n.directory === directory)
+ },
+ unseen(directory: string) {
+ return store.list.filter((n) => n.directory === directory && !n.viewed)
+ },
+ markViewed(directory: string) {
+ setStore("list", (n) => n.directory === directory, "viewed", true)
+ },
+ },
+ }
+ },
+})
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 85758c5b6..2ab54b3ae 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -65,6 +65,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
},
more: createMemo(() => store.session.length >= store.limit),
+ archive: async (sessionID: string) => {
+ await sdk.client.session.update({ sessionID, time: { archived: Date.now() } })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, sessionID, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ },
},
absolute,
get directory() {
diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
index 205ffd815..7cd2916e8 100644
--- a/packages/desktop/src/pages/home.tsx
+++ b/packages/desktop/src/pages/home.tsx
@@ -1,5 +1,5 @@
import { useGlobalSync } from "@/context/global-sync"
-import { For, Match, Show, Switch } from "solid-js"
+import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { useLayout } from "@/context/layout"
@@ -14,6 +14,7 @@ export default function Home() {
const layout = useLayout()
const platform = usePlatform()
const navigate = useNavigate()
+ const homedir = createMemo(() => sync.data.path.home)
function openProject(directory: string) {
layout.projects.open(directory)
@@ -61,7 +62,7 @@ export default function Home() {
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
- {project.worktree}
+ {project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index b997296fa..7da920c5f 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -1,10 +1,21 @@
-import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onCleanup,
+ onMount,
+ ParentProps,
+ Show,
+ Switch,
+ type JSX,
+} from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
-import { Mark } from "@opencode-ai/ui/logo"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
@@ -15,7 +26,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
-import { Select } from "@opencode-ai/ui/select"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
@@ -42,6 +52,9 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { showToast, Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { Spinner } from "@opencode-ai/ui/spinner"
+import { useNotification } from "@/context/notification"
+import { Binary } from "@opencode-ai/util/binary"
+import { Header } from "@/components/header"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -54,10 +67,8 @@ export default function Layout(props: ParentProps) {
const globalSync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
+ const notification = useNotification()
const navigate = useNavigate()
- 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 = useProviders()
function navigateToProject(directory: string | undefined) {
@@ -77,9 +88,11 @@ export default function Layout(props: ParentProps) {
}
function closeProject(directory: string) {
+ const index = layout.projects.list().findIndex((x) => x.worktree === directory)
+ const next = layout.projects.list()[index + 1]
layout.projects.close(directory)
- // TODO: more intelligent navigation
- navigate("/")
+ if (next) navigateToProject(next.worktree)
+ else navigate("/")
}
async function chooseProject() {
@@ -105,6 +118,7 @@ export default function Layout(props: ParentProps) {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
setStore("lastSession", directory, params.id)
+ notification.session.markViewed(params.id)
})
createEffect(() => {
@@ -164,8 +178,51 @@ export default function Layout(props: ParentProps) {
return <></>
}
+ const ProjectAvatar = (props: {
+ project: Project
+ class?: string
+ expandable?: boolean
+ notify?: boolean
+ }): JSX.Element => {
+ const notification = useNotification()
+ const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ const name = createMemo(() => getFilename(props.project.worktree))
+ const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
+ return (
+ <div class="relative size-6 shrink-0">
+ <Avatar
+ fallback={name()}
+ src={props.project.icon?.url}
+ {...getAvatarColors(props.project.icon?.color)}
+ class={`size-full ${props.class ?? ""}`}
+ style={
+ notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
+ }
+ />
+ <Show when={props.expandable}>
+ <Icon
+ name="chevron-right"
+ size="large"
+ class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
+ />
+ </Show>
+ <Show when={notifications().length > 0 && props.notify}>
+ <div
+ classList={{
+ "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
+ "bg-icon-critical-base": hasError(),
+ "bg-text-interactive-base": !hasError(),
+ }}
+ />
+ </Show>
+ </div>
+ )
+ }
+
const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
const name = createMemo(() => getFilename(props.project.worktree))
+ const current = createMemo(() => base64Decode(params.dir ?? ""))
return (
<Switch>
<Match when={layout.sidebar.opened()}>
@@ -176,14 +233,7 @@ export default function Layout(props: ParentProps) {
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
>
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
- <div class="size-6 shrink-0">
- <Avatar
- fallback={name()}
- src={props.project.icon?.url}
- {...getAvatarColors(props.project.icon?.color)}
- class="size-full"
- />
- </div>
+ <ProjectAvatar project={props.project} />
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</div>
</Button>
@@ -193,17 +243,10 @@ export default function Layout(props: ParentProps) {
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
- data-selected={props.project.worktree === currentDirectory()}
+ data-selected={props.project.worktree === current()}
onClick={() => navigateToProject(props.project.worktree)}
>
- <div class="size-6 shrink-0">
- <Avatar
- fallback={name()}
- src={props.project.icon?.url}
- {...getAvatarColors(props.project.icon?.color)}
- class="size-full"
- />
- </div>
+ <ProjectAvatar project={props.project} notify />
</Button>
</Match>
</Switch>
@@ -211,35 +254,31 @@ export default function Layout(props: ParentProps) {
}
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
+ const notification = useNotification()
const sortable = createSortable(props.project.worktree)
- const [projectStore] = globalSync.child(props.project.worktree)
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
+ const [store, setStore] = globalSync.child(props.project.worktree)
+ const sessions = createMemo(() => store.session ?? [])
+ const [expanded, setExpanded] = createSignal(true)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
<Match when={layout.sidebar.opened()}>
- <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
+ <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none rounded-lg"
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
- <div class="size-6 shrink-0">
- <Avatar
- fallback={name()}
- src={props.project.icon?.url}
- {...getAvatarColors(props.project.icon?.color)}
- class="size-full group-hover/session:hidden"
- />
- <Icon
- name="chevron-right"
- size="large"
- class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
- />
- </div>
+ <ProjectAvatar
+ project={props.project}
+ class="group-hover/session:hidden"
+ expandable
+ notify={!expanded()}
+ />
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</Collapsible.Trigger>
<div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
@@ -260,50 +299,102 @@ export default function Layout(props: ParentProps) {
</Button>
<Collapsible.Content>
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
- <For each={projectStore.session}>
+ <For each={sessions()}>
{(session) => {
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+ const notifications = createMemo(() => notification.session.unseen(session.id))
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+ async function archive(session: Session) {
+ await globalSDK.client.session.update({
+ directory: session.directory,
+ sessionID: session.id,
+ time: { archived: Date.now() },
+ })
+ setStore(
+ produce((draft) => {
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
+ if (match.found) draft.session.splice(match.index, 1)
+ }),
+ )
+ }
return (
<A
- data-active={session.id === params.id}
href={`${slug()}/session/${session.id}`}
class="group/session focus:outline-none cursor-default"
>
<Tooltip placement="right" value={session.title}>
<div
- class="w-full pl-4 pr-2 py-1 rounded-md
- group-data-[active=true]/session:bg-surface-raised-base-hover
- group-hover/session:bg-surface-raised-base-hover
- group-focus/session:bg-surface-raised-base-hover"
+ class="relative w-full pl-4 pr-1 py-1 rounded-md
+ group-[.active]/session:bg-surface-raised-base-hover
+ group-hover/session:bg-surface-raised-base-hover
+ group-focus/session:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
{session.title}
</span>
- <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
- {Math.abs(updated().diffNow().as("seconds")) < 60
- ? "Now"
- : updated()
- .toRelative({
- style: "short",
- unit: ["days", "hours", "minutes"],
- })
- ?.replace(" ago", "")
- ?.replace(/ days?/, "d")
- ?.replace(" min.", "m")
- ?.replace(" hr.", "h")}
- </span>
- </div>
- <div class="hidden _flex justify-between items-center self-stretch">
- <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
- <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ <div class="shrink-0 group-hover/session:hidden mr-1">
+ <Switch>
+ <Match when={hasError()}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={notifications().length > 0}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ <Match when={true}>
+ <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+ {Math.abs(updated().diffNow().as("seconds")) < 60
+ ? "Now"
+ : updated()
+ .toRelative({
+ style: "short",
+ unit: ["days", "hours", "minutes"],
+ })
+ ?.replace(" ago", "")
+ ?.replace(/ days?/, "d")
+ ?.replace(" min.", "m")
+ ?.replace(" hr.", "h")}
+ </span>
+ </Match>
+ </Switch>
+ </div>
+ <div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1">
+ {/* <IconButton icon="dot-grid" variant="ghost" /> */}
+ <Tooltip placement="right" value="Archive session">
+ <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
+ </Tooltip>
+ </div>
</div>
+ <Show when={session.summary?.files}>
+ <div class="flex justify-between items-center self-stretch">
+ <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+ <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ </div>
+ </Show>
</div>
</Tooltip>
</A>
)
}}
</For>
+ <Show when={sessions().length === 0}>
+ <A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
+ <Tooltip placement="right" value="New session">
+ <div
+ class="relative w-full pl-4 pr-1 py-1 rounded-md
+ group-[.active]/session:bg-surface-raised-base-hover
+ group-hover/session:bg-surface-raised-base-hover
+ group-focus/session:bg-surface-raised-base-hover"
+ >
+ <div class="flex items-center self-stretch gap-6 justify-between">
+ <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+ New session
+ </span>
+ </div>
+ </div>
+ </Tooltip>
+ </A>
+ </Show>
</nav>
</Collapsible.Content>
</Collapsible>
@@ -332,93 +423,9 @@ export default function Layout(props: ParentProps) {
}
return (
- <div class="relative h-screen flex flex-col">
- <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
- <A
- href="/"
- classList={{
- "w-12 shrink-0 px-4 py-3.5": true,
- "flex items-center justify-start self-stretch": true,
- "border-r border-border-weak-base": true,
- }}
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
- data-tauri-drag-region
- >
- <Mark class="shrink-0" />
- </A>
- <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
- <Show when={params.dir && layout.projects.list().length > 0}>
- <div class="flex items-center gap-3">
- <div class="flex items-center gap-2">
- <Select
- options={layout.projects.list().map((project) => project.worktree)}
- current={currentDirectory()}
- label={(x) => getFilename(x)}
- onSelect={(x) => (x ? navigateToProject(x) : undefined)}
- class="text-14-regular text-text-base"
- variant="ghost"
- >
- {/* @ts-ignore */}
- {(i) => (
- <div class="flex items-center gap-2">
- <Icon name="folder" size="small" />
- <div class="text-text-strong">{getFilename(i)}</div>
- </div>
- )}
- </Select>
- <div class="text-text-weaker">/</div>
- <Select
- options={sessions()}
- current={currentSession()}
- placeholder="New session"
- label={(x) => x.title}
- value={(x) => x.id}
- onSelect={navigateToSession}
- class="text-14-regular text-text-base max-w-md"
- variant="ghost"
- />
- </div>
- <Show when={currentSession()}>
- <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
- New session
- </Button>
- </Show>
- </div>
- <div class="flex items-center gap-4">
- <Tooltip
- class="shrink-0"
- value={
- <div class="flex items-center gap-2">
- <span>Toggle terminal</span>
- <span class="text-icon-base text-12-medium">Ctrl `</span>
- </div>
- }
- >
- <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
- <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon
- size="small"
- name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
- class="group-hover/terminal-toggle:hidden"
- />
- <Icon
- size="small"
- name="layout-bottom-partial"
- class="hidden group-hover/terminal-toggle:inline-block"
- />
- <Icon
- size="small"
- name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
- class="hidden group-active/terminal-toggle:inline-block"
- />
- </div>
- </Button>
- </Tooltip>
- </div>
- </Show>
- </div>
- </header>
- <div class="h-[calc(100vh-3rem)] flex">
+ <div class="relative flex-1 min-h-0 flex flex-col">
+ <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
+ <div class="flex-1 min-h-0 flex">
<div
classList={{
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 5dae4ce55..8ea9f87e1 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -675,7 +675,7 @@ export default function Page() {
<For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
- <Tooltip value="Open file" class="flex items-center">
+ <Tooltip value="New Terminal" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
</Tooltip>
</div>
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index a1e45e1d2..d20c971eb 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -914,6 +914,8 @@ export namespace ACP {
{
sessionID,
directory,
+ providerID: model.providerID,
+ modelID: model.modelID,
},
{ throwOnError: true },
)
diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
index c62630e0c..398aff5af 100644
--- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
@@ -61,7 +61,7 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
- if (os === "darwin" && Bun.which("oascript")) {
+ if (os === "darwin" && Bun.which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index c0ee45236..5873ec7b5 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -272,7 +272,7 @@ export namespace ProviderTransform {
const options: Record<string, any> = {}
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
- if (model.api.id.includes("5.1")) {
+ if (model.api.id.includes("5.")) {
options["reasoningEffort"] = "low"
} else {
options["reasoningEffort"] = "minimal"
diff --git a/packages/tauri/index.html b/packages/tauri/index.html
index 0ac3d566d..8de2d21d0 100644
--- a/packages/tauri/index.html
+++ b/packages/tauri/index.html
@@ -14,7 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
- <body class="antialiased overscroll-none select-none text-12-regular">
+ <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
@@ -22,7 +22,7 @@
})()
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
- <div id="root"></div>
+ <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>
diff --git a/packages/tauri/package.json b/packages/tauri/package.json
index 7e0f670b4..d712f15f4 100644
--- a/packages/tauri/package.json
+++ b/packages/tauri/package.json
@@ -16,6 +16,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2",
+ "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-shell": "~2",
"@tauri-apps/plugin-store": "~2",
diff --git a/packages/tauri/src-tauri/Cargo.lock b/packages/tauri/src-tauri/Cargo.lock
index b42329d75..42a7dae81 100644
--- a/packages/tauri/src-tauri/Cargo.lock
+++ b/packages/tauri/src-tauri/Cargo.lock
@@ -1257,6 +1257,16 @@ dependencies = [
]
[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link 0.2.1",
+]
+
+[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2310,6 +2320,16 @@ dependencies = [
]
[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2 0.6.3",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2440,6 +2460,7 @@ checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2 0.6.3",
+ "objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -2461,8 +2482,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.10.0",
+ "block2 0.6.2",
"objc2 0.6.3",
+ "objc2-cloud-kit",
+ "objc2-core-data",
"objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2 0.6.3",
"objc2-foundation 0.3.2",
]
@@ -2511,6 +2551,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
+ "tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-store",
@@ -2536,6 +2577,22 @@ dependencies = [
]
[[package]]
+name = "os_info"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c39b5918402d564846d5aba164c09a66cc88d232179dfd3e3c619a25a268392"
+dependencies = [
+ "android_system_properties",
+ "log",
+ "nix",
+ "objc2 0.6.3",
+ "objc2-foundation 0.3.2",
+ "objc2-ui-kit",
+ "serde",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3873,6 +3930,15 @@ dependencies = [
]
[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "system-deps"
version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4147,6 +4213,24 @@ dependencies = [
]
[[package]]
+name = "tauri-plugin-os"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997"
+dependencies = [
+ "gethostname",
+ "log",
+ "os_info",
+ "serde",
+ "serde_json",
+ "serialize-to-javascript",
+ "sys-locale",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.17",
+]
+
+[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/packages/tauri/src-tauri/Cargo.toml b/packages/tauri/src-tauri/Cargo.toml
index f72e5f428..a39c22011 100644
--- a/packages/tauri/src-tauri/Cargo.toml
+++ b/packages/tauri/src-tauri/Cargo.toml
@@ -31,3 +31,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = "1.48.0"
listeners = "0.3"
+tauri-plugin-os = "2"
diff --git a/packages/tauri/src-tauri/capabilities/default.json b/packages/tauri/src-tauri/capabilities/default.json
index 320586de4..91af8cbdc 100644
--- a/packages/tauri/src-tauri/capabilities/default.json
+++ b/packages/tauri/src-tauri/capabilities/default.json
@@ -13,6 +13,7 @@
"dialog:default",
"process:default",
"store:default",
- "window-state:default"
+ "window-state:default",
+ "os:default"
]
}
diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs
index eb712235e..fea3addb5 100644
--- a/packages/tauri/src-tauri/src/lib.rs
+++ b/packages/tauri/src-tauri/src/lib.rs
@@ -4,7 +4,9 @@ use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
-use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow};
+use tauri::{
+ AppHandle, LogicalSize, Manager, Monitor, RunEvent, TitleBarStyle, WebviewUrl, WebviewWindow,
+};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
@@ -107,6 +109,7 @@ pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
let mut builder = tauri::Builder::default()
+ .plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
@@ -180,6 +183,7 @@ pub fn run() {
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
+ .title_bar_style(TitleBarStyle::Overlay)
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
diff --git a/packages/tauri/src/index.tsx b/packages/tauri/src/index.tsx
index c72805fe6..84ba73c07 100644
--- a/packages/tauri/src/index.tsx
+++ b/packages/tauri/src/index.tsx
@@ -5,6 +5,7 @@ import { runUpdater } from "./updater"
import { onMount } from "solid-js"
import { open, save } from "@tauri-apps/plugin-dialog"
import { open as shellOpen } from "@tauri-apps/plugin-shell"
+import { type as ostype } from "@tauri-apps/plugin-os"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -54,6 +55,9 @@ render(() => {
return (
<PlatformProvider value={platform}>
+ {ostype() === "macos" && (
+ <div class="bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
+ )}
<App />
</PlatformProvider>
)
diff --git a/packages/ui/package.json b/packages/ui/package.json
index e7bcbbf79..7aede1dcd 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -12,7 +12,8 @@
"./styles/tailwind": "./src/styles/tailwind/index.css",
"./icons/provider": "./src/components/provider-icons/types.ts",
"./icons/file-type": "./src/components/file-icons/types.ts",
- "./fonts/*": "./src/assets/fonts/*"
+ "./fonts/*": "./src/assets/fonts/*",
+ "./audio/*": "./src/assets/audio/*"
},
"scripts": {
"typecheck": "tsgo --noEmit",
diff --git a/packages/ui/src/assets/audio/staplebops-01.aac b/packages/ui/src/assets/audio/staplebops-01.aac
new file mode 100644
index 000000000..01ae83db7
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-01.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-02.aac b/packages/ui/src/assets/audio/staplebops-02.aac
new file mode 100644
index 000000000..698137c26
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-02.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-03.aac b/packages/ui/src/assets/audio/staplebops-03.aac
new file mode 100644
index 000000000..5efa4451e
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-03.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-04.aac b/packages/ui/src/assets/audio/staplebops-04.aac
new file mode 100644
index 000000000..02d6bd5d7
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-04.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-05.aac b/packages/ui/src/assets/audio/staplebops-05.aac
new file mode 100644
index 000000000..7f0de4aa5
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-05.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-06.aac b/packages/ui/src/assets/audio/staplebops-06.aac
new file mode 100644
index 000000000..0c010dfb0
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-06.aac
Binary files differ
diff --git a/packages/ui/src/assets/audio/staplebops-07.aac b/packages/ui/src/assets/audio/staplebops-07.aac
new file mode 100644
index 000000000..7d20ce755
--- /dev/null
+++ b/packages/ui/src/assets/audio/staplebops-07.aac
Binary files differ
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 79cd85532..0dbd7a650 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -4,6 +4,7 @@ const icons = {
"align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
"arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
+ archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
@@ -23,7 +24,7 @@ const icons = {
folder: `<path d="M2.08301 2.91675V16.2501H17.9163V5.41675H9.99967L8.33301 2.91675H2.08301Z" stroke="currentColor" stroke-linecap="round"/>`,
"magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
- "plus": `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
+ plus: `<path d="M9.9987 2.20703V9.9987M9.9987 9.9987V17.7904M9.9987 9.9987H2.20703M9.9987 9.9987H17.7904" stroke="currentColor" stroke-linecap="square"/>`,
"pencil-line": `<path d="M9.58301 17.9166H17.9163M17.9163 5.83325L14.1663 2.08325L2.08301 14.1666V17.9166H5.83301L17.9163 5.83325Z" stroke="currentColor" stroke-linecap="square"/>`,
mcp: `<g><path d="M0.972656 9.37176L9.5214 1.60019C10.7018 0.527151 12.6155 0.527151 13.7957 1.60019C14.9761 2.67321 14.9761 4.41295 13.7957 5.48599L7.3397 11.3552" stroke="currentColor" stroke-linecap="round"/><path d="M7.42871 11.2747L13.7957 5.48643C14.9761 4.41338 16.8898 4.41338 18.0702 5.48643L18.1147 5.52688C19.2951 6.59993 19.2951 8.33966 18.1147 9.4127L10.3831 16.4414C9.98966 16.7991 9.98966 17.379 10.3831 17.7366L11.9707 19.1799" stroke="currentColor" stroke-linecap="round"/><path d="M11.6587 3.54346L5.33619 9.29119C4.15584 10.3642 4.15584 12.1039 5.33619 13.177C6.51649 14.25 8.43019 14.25 9.61054 13.177L15.9331 7.42923" stroke="currentColor" stroke-linecap="round"/></g>`,
glasses: `<path d="M0.416626 7.91667H1.66663M19.5833 7.91667H18.3333M11.866 7.57987C11.3165 7.26398 10.6793 7.08333 9.99996 7.08333C9.32061 7.08333 8.68344 7.26398 8.13389 7.57987M8.74996 10C8.74996 12.0711 7.07103 13.75 4.99996 13.75C2.92889 13.75 1.24996 12.0711 1.24996 10C1.24996 7.92893 2.92889 6.25 4.99996 6.25C7.07103 6.25 8.74996 7.92893 8.74996 10ZM18.75 10C18.75 12.0711 17.071 13.75 15 13.75C12.9289 13.75 11.25 12.0711 11.25 10C11.25 7.92893 12.9289 6.25 15 6.25C17.071 6.25 18.75 7.92893 18.75 10Z" stroke="currentColor" stroke-linecap="square"/>`,
diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css
index fbc84f13c..374dd6523 100644
--- a/packages/ui/src/components/toast.css
+++ b/packages/ui/src/components/toast.css
@@ -31,7 +31,7 @@
border-radius: var(--radius-lg);
border: 1px solid var(--border-weak-base);
background: var(--surface-float-base);
- color: var(--text-inverted-base);
+ color: var(--text-invert-base);
box-shadow: var(--shadow-md);
[data-slot="toast-inner"] {
@@ -80,7 +80,8 @@
justify-content: center;
[data-component="icon"] {
- color: rgba(253, 252, 252, 0.94);
+ color: var(--text-invert-stronger);
+ /* color: var(--icon-invert-base); */
}
}
@@ -93,7 +94,7 @@
}
[data-slot="toast-title"] {
- color: var(--text-inverted-strong);
+ color: var(--text-invert-strong);
/* text-14-medium */
font-family: var(--font-family-sans);
@@ -107,7 +108,8 @@
}
[data-slot="toast-description"] {
- color: var(--text-inverted-base);
+ color: var(--text-invert-base);
+ text-wrap-style: pretty;
/* text-14-regular */
font-family: var(--font-family-sans);
@@ -132,7 +134,7 @@
padding: 0;
cursor: pointer;
- color: var(--text-inverted-strong);
+ color: var(--text-invert-strong);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
@@ -144,7 +146,7 @@
}
&:last-child {
- color: var(--text-inverted-weak);
+ color: var(--text-invert-weak);
}
}
diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx
index 2ea266bd4..36759d503 100644
--- a/packages/web/src/content/docs/zen.mdx
+++ b/packages/web/src/content/docs/zen.mdx
@@ -64,6 +64,7 @@ You can also access our models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package |
| ----------------- | ----------------- | ------------------------------------------------ | --------------------------- |
+| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -122,6 +123,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
+| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |