feat(desktop): add menu-bar tray companion

This commit is contained in:
David Hill
2026-09-30 12:58:00 -06:00
parent 7878744505
commit 01f49ef1cf
73 changed files with 2648 additions and 61 deletions
+28
View File
@@ -412,11 +412,13 @@
"name": "@opencode/desktop",
"version": "2.0.20",
"dependencies": {
"@resvg/resvg-js": "2.6.2",
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
"string-width": "catalog:",
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
@@ -2637,6 +2639,32 @@
"@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="],
"@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="],
"@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="],
"@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="],
"@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="],
"@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="],
"@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="],
"@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="],
"@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="],
"@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="],
"@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="],
"@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="],
"@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="],
"@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
+2
View File
@@ -12,6 +12,8 @@
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:tray": "bun --cwd packages/desktop dev --tray",
"dev:tray:live": "bun --cwd packages/desktop ./scripts/dev-live.ts",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
+1
View File
@@ -7,6 +7,7 @@
".": "./src/index.ts",
"./desktop": "./src/desktop.ts",
"./desktop-menu": "./src/shell/commands/desktop-menu.ts",
"./project-avatar-source": "./src/shell/layout/project-avatar-source.ts",
"./i18n/desktop-native": "./src/runtime/i18n/desktop-native.ts",
"./updater": "./src/shell/updates/types.ts",
"./wsl/types": "./src/servers/wsl/types.ts",
+4 -1
View File
@@ -1,6 +1,8 @@
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
export { useCommand } from "./shell/commands/command"
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { currentRoute, getProjectAvatarVariant, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
export { displayName, projectForSession } from "./shell/layout/helpers"
export { useServerCtx } from "./runtime/server/runtime"
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
export type {
@@ -17,6 +19,7 @@ export type {
export { ServerConnection, useServers } from "./runtime/server/registry"
export { useGlobal } from "./runtime/server/runtime"
export { useTabs } from "./shell/tabs/tabs"
export { sessionHref } from "./shell/routes/session"
export { createDraftStore } from "./runtime/persistence/drafts"
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
export { flushPersisted } from "./runtime/persistence/persist"
@@ -9,10 +9,16 @@ import {
detectDesktopNativeLocale,
DESKTOP_NATIVE_MAX_PAYLOAD_BYTES,
formatDesktopNativeMessage,
formatDesktopNativeCost,
parseDesktopNativeBundle,
} from "./desktop-native"
describe("desktop native translations", () => {
test("formats session costs as USD using the active native locale", () => {
expect(formatDesktopNativeCost("en", 0)).toBe("$0.00")
expect(formatDesktopNativeCost("en", 12.34)).toBe("$12.34")
expect(formatDesktopNativeCost("de", 12.34)).toContain("12,34")
})
test("uses native language names independent of the active locale", () => {
expect(DESKTOP_NATIVE_LOCALES.map((locale) => DESKTOP_NATIVE_LABELS[locale])).toEqual([
"English",
@@ -275,6 +275,36 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.reportBug": "Report a Bug",
"desktop.menu.ariaLabel": "OpenCode menu",
"desktop.tray.open": "Open OpenCode",
"desktop.tray.newAgent": "New Agent…",
"desktop.tray.quit": "Quit",
"desktop.tray.docs": "Docs",
"desktop.tray.summary": "Working: {{working}} · Needs you: {{attention}}",
"desktop.tray.loading": "Loading sessions…",
"desktop.tray.starting": "Starting OpenCode…",
"desktop.tray.offline": "Unable to refresh sessions",
"desktop.tray.stale": "Showing the last available update",
"desktop.tray.empty": "No active sessions",
"desktop.tray.more": "View all sessions…",
"desktop.tray.untitled": "Untitled session",
"desktop.tray.group.attention": "Needs you",
"desktop.tray.group.working": "Working",
"desktop.tray.group.active": "Active sessions",
"desktop.tray.status.question": "Question waiting",
"desktop.tray.status.permission": "Permission needed",
"desktop.tray.status.working": "Working",
"desktop.tray.status.failed": "Failed",
"desktop.tray.status.interrupted": "Stopped",
"desktop.tray.status.idle": "Idle",
"desktop.tray.context.percent": "Context {{percent}}%",
"desktop.tray.context.tokens": "Context tokens {{tokens}}",
"desktop.tray.context.unknown": "No context",
"desktop.tray.session.detail": "{{project}} · {{context}} · {{cost}}",
"desktop.tray.tooltip.cost": "Cost {{cost}}",
"desktop.tray.tooltip.local": "Local",
"desktop.tray.tooltip.worktree": "Worktree · {{name}}",
"desktop.tray.tooltip.placementUnknown": "Location unavailable",
"desktop.updater.dialog.checkFailed.message": "Update check failed.",
"desktop.updater.dialog.checkFailed.title": "Update Error",
"desktop.updater.dialog.upToDate.message": "You're up to date.",
@@ -375,3 +405,7 @@ export function formatDesktopNativeMessage(message: string, params?: Record<stri
return value === undefined ? match : String(value)
})
}
export function formatDesktopNativeCost(locale: DesktopNativeLocale, value: number) {
return new Intl.NumberFormat(DESKTOP_NATIVE_LOCALE_TAGS[locale], { style: "currency", currency: "USD" }).format(value)
}
+3
View File
@@ -1348,6 +1348,9 @@ export const dict = {
"Show edit, write, and patch tool parts expanded by default in the timeline",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
"settings.general.row.tray.title": "Menu bar / system tray",
"settings.general.row.tray.description":
"Show OpenCode in the menu bar or system tray and keep the app available after closing its windows.",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
@@ -111,6 +111,9 @@ type PlatformBase = {
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Show the bundled menu bar/system tray companion (desktop only). */
getTrayEnabled?(): Promise<boolean>
setTrayEnabled?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -342,6 +342,15 @@ export const SettingsGeneral: Component = () => {
void update.catch(() => setPinchZoom(!checked))
}
const [tray, { mutate: setTray }] = createResource(
() => desktop() && platform.getTrayEnabled,
(get) => get(),
)
const onTrayChange = (checked: boolean) => {
setTray(checked)
void platform.setTrayEnabled?.(checked).catch(() => setTray(!checked))
}
const GeneralSection = () => (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.general")}</h3>
@@ -376,6 +385,21 @@ export const SettingsGeneral: Component = () => {
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={platform.getTrayEnabled}>
<SettingsRow
title={language.t("settings.general.row.tray.title")}
description={language.t("settings.general.row.tray.description")}
>
<div data-action="settings-tray">
<Switch
aria-label={language.t("settings.general.row.tray.title")}
checked={tray.latest ?? true}
disabled={tray.loading || !!tray.error}
onChange={onTrayChange}
/>
</div>
</SettingsRow>
</Show>
</Show>
<SettingsRow
+1 -8
View File
@@ -96,14 +96,7 @@ export function homeSessionServerStatus(active: boolean, status: () => { working
return status()
}
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon.override
if (icon?.color) return undefined
return icon?.url
}
export { getProjectAvatarSource } from "./project-avatar-source"
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: SessionInfo,
@@ -0,0 +1,8 @@
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon.override
if (icon?.color) return undefined
return icon?.url
}
+32 -3
View File
@@ -9,7 +9,7 @@ import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
@@ -56,7 +56,10 @@ export default Runtime.handler(Commands, (input) =>
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
findSession(
OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }),
session,
),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
@@ -77,6 +80,27 @@ export default Runtime.handler(Commands, (input) =>
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const service = server.service
const global = yield* Global.Service
const { createTerminalTray, focusTerminal, hasDesktop, launchTerminalTray } = yield* Effect.promise(
() => import("../../services/terminal-tray"),
)
const tray = hasDesktop(process.env, process.platform, !!process.stdout.isTTY)
? yield* Effect.tryPromise(() =>
createTerminalTray({
endpoint: server.endpoint,
directory: global.state + "/terminal-tray",
enabled: false,
launch: (file) => launchTerminalTray(file, OPENCODE_LOCAL, global.log),
focus: focusTerminal,
log: (message, error) => {
runFork(Effect.logWarning(message, { error }))
},
}),
).pipe(
Effect.catch((error) => Effect.logWarning("Terminal tray unavailable", { error }).pipe(Effect.as(undefined))),
)
: undefined
if (tray) yield* Effect.addFinalizer(() => Effect.promise(() => tray.close()))
yield* run({
app: {
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
@@ -87,7 +111,11 @@ export default Runtime.handler(Commands, (input) =>
endpoint: server.endpoint,
service: service
? {
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
reconnect: async (signal) => {
const endpoint = await runServicePromise(service.reconnect(), { signal })
tray?.endpoint(endpoint)
return endpoint
},
restart: () => runServicePromise(service.restart()),
}
: undefined,
@@ -99,6 +127,7 @@ export default Runtime.handler(Commands, (input) =>
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
tray,
config: {
path: config.path,
get: () => runPromise(config.get()),
+139
View File
@@ -0,0 +1,139 @@
import { TerminalTray } from "@opencode/schema/terminal-tray"
import type { Endpoint } from "@opencode/client/service"
import { Option, Schema } from "effect"
import { createServer } from "node:http"
import { randomUUID } from "node:crypto"
import { mkdir, open, rm, writeFile, access } from "node:fs/promises"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { spawn, execFile } from "node:child_process"
const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(TerminalTray.Command))
export function hasDesktop(env: NodeJS.ProcessEnv, platform: string, tty: boolean) {
if (!tty || env.OPENCODE_TRAY === "0" || env.OPENCODE_TRAY === "false") return false
if (env.SSH_CONNECTION || env.SSH_TTY) return false
return platform === "darwin" || platform === "win32" || !!(env.DISPLAY || env.WAYLAND_DISPLAY)
}
export async function createTerminalTray(input: {
endpoint: Endpoint
directory: string
enabled: boolean
launch: (file: string) => Promise<void>
focus: () => void
log: (message: string, error: unknown) => void
}) {
const token = randomUUID()
const state = {
value: { enabled: false, focused: true, sessions: [] } as TerminalTray.State,
endpoint: input.endpoint,
}
const listeners = new Set<(command: TerminalTray.Command) => void>()
const pending: TerminalTray.Command[] = []
const server = createServer((request, response) => {
if (request.headers.authorization !== `Bearer ${token}`) {
response.writeHead(401).end()
return
}
if (request.method === "GET" && request.url === "/state") {
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" })
response.end(JSON.stringify({ ...state.value, server: state.endpoint } satisfies TerminalTray.Snapshot))
return
}
if (request.method !== "POST" || request.url !== "/command") {
response.writeHead(404).end()
return
}
const chunks: Buffer[] = []
let size = 0
request.on("data", (chunk: Buffer) => {
size += chunk.length
if (size > 16_384) {
response.writeHead(413).end()
request.destroy()
return
}
chunks.push(chunk)
})
request.on("end", () => {
const command = Option.getOrUndefined(decodeCommand(Buffer.concat(chunks).toString("utf8")))
if (!command) {
response.writeHead(400).end()
return
}
input.focus()
if (!listeners.size) pending.push(command)
listeners.forEach((listener) => listener(command))
response.writeHead(204).end()
})
})
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(0, "127.0.0.1", () => resolve())
})
const address = server.address()
if (!address || typeof address === "string") throw new Error("Terminal tray bridge did not bind a TCP port")
const connection: TerminalTray.Connection = { id: randomUUID(), url: `http://127.0.0.1:${address.port}`, token }
await mkdir(input.directory, { recursive: true, mode: 0o700 })
const file = join(input.directory, `${connection.id}.json`)
await writeFile(file, JSON.stringify(connection), { mode: 0o600 })
const update = (value: TerminalTray.State) => {
const launch = value.enabled && !state.value.enabled
state.value = value
if (launch) void input.launch(file).catch((error) => input.log("Could not launch the menu-bar companion", error))
}
update({ enabled: input.enabled, focused: true, sessions: [] })
return {
connection,
update,
endpoint(endpoint: Endpoint) {
state.endpoint = endpoint
},
subscribe(listener: (command: TerminalTray.Command) => void) {
listeners.add(listener)
pending.splice(0).forEach(listener)
return () => {
listeners.delete(listener)
}
},
async close() {
state.value = { enabled: false, focused: false, sessions: [] }
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(file, { force: true })
},
}
}
export async function launchTerminalTray(file: string, local: boolean, logDirectory: string) {
const override = process.env.OPENCODE_TRAY_EXECUTABLE
const script = fileURLToPath(new URL("../../../desktop/scripts/dev-terminal-tray.ts", import.meta.url))
const executable =
override ??
(local
? process.execPath
: join(dirname(process.execPath), "opencode-tray" + (process.platform === "win32" ? ".exe" : "")))
await access(override || !local ? executable : script)
await mkdir(logDirectory, { recursive: true })
const log = await open(join(logDirectory, "terminal-tray.log"), "a", 0o600)
const child = spawn(executable, override || !local ? [`--terminal-tray=${file}`] : [script, file], {
cwd: local && !override ? dirname(dirname(script)) : undefined,
env: process.env,
stdio: ["ignore", log.fd, log.fd],
detached: true,
windowsHide: true,
})
await new Promise<void>((resolve, reject) => {
child.once("spawn", resolve)
child.once("error", reject)
}).finally(() => log.close())
child.unref()
}
export function focusTerminal() {
if (process.platform !== "darwin") return
const bundle = process.env.__CFBundleIdentifier
if (!bundle || !/^[a-zA-Z0-9.-]+$/.test(bundle)) return
execFile("osascript", ["-e", `tell application id "${bundle}" to activate`], () => {})
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile, rm, stat } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createTerminalTray, hasDesktop } from "../src/services/terminal-tray"
import type { TerminalTray } from "@opencode/schema/terminal-tray"
describe("terminal tray bridge", () => {
test("enables the companion only for local graphical interactive terminals", () => {
expect(hasDesktop({}, "darwin", true)).toBe(true)
expect(hasDesktop({}, "win32", true)).toBe(true)
expect(hasDesktop({ DISPLAY: ":0" }, "linux", true)).toBe(true)
expect(hasDesktop({}, "linux", true)).toBe(false)
expect(hasDesktop({ SSH_CONNECTION: "remote" }, "darwin", true)).toBe(false)
expect(hasDesktop({ OPENCODE_TRAY: "false" }, "darwin", true)).toBe(false)
expect(hasDesktop({}, "darwin", false)).toBe(false)
})
test("authenticates the bridge, publishes tabs, routes actions, and cleans up", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-terminal-tray-"))
const launched: string[] = []
const focused: boolean[] = []
const commands: TerminalTray.Command[] = []
const bridge = await createTerminalTray({
endpoint: {
url: "http://127.0.0.1:4096",
auth: { type: "basic", username: "opencode", password: "test-password" },
},
directory,
enabled: false,
launch: async (file) => {
launched.push(file)
},
focus: () => {
focused.push(true)
},
log: (message) => {
throw new Error(message)
},
})
try {
expect((await fetch(`${bridge.connection.url}/state`)).status).toBe(401)
const headers = { authorization: `Bearer ${bridge.connection.token}` }
bridge.update({ enabled: true, focused: true, sessions: ["ses_one"], current: "ses_one" })
expect(launched).toHaveLength(1)
expect(JSON.parse(await readFile(launched[0], "utf8"))).toEqual(bridge.connection)
expect((await stat(launched[0])).mode & 0o777).toBe(0o600)
const state = await fetch(`${bridge.connection.url}/state`, { headers }).then((response) => response.json())
expect(state.sessions).toEqual(["ses_one"])
expect(state.server.url).toBe("http://127.0.0.1:4096")
expect(
(
await fetch(`${bridge.connection.url}/command`, {
method: "POST",
headers,
body: JSON.stringify({ type: "new" }),
})
).status,
).toBe(204)
const unsubscribe = bridge.subscribe((command) => commands.push(command))
expect(commands).toEqual([{ type: "new" }])
await fetch(`${bridge.connection.url}/command`, {
method: "POST",
headers,
body: JSON.stringify({ type: "session", sessionID: "ses_one" }),
})
expect(commands.at(-1)).toEqual({ type: "session", sessionID: "ses_one" })
expect(focused).toHaveLength(2)
expect(
(
await fetch(`${bridge.connection.url}/command`, {
method: "POST",
headers,
body: JSON.stringify({ type: "invalid" }),
})
).status,
).toBe(400)
bridge.update({ enabled: true, focused: true, sessions: ["ses_one"] })
expect(launched).toHaveLength(1)
bridge.update({ enabled: false, focused: true, sessions: ["ses_one"] })
bridge.update({ enabled: true, focused: true, sessions: ["ses_one"] })
expect(launched).toHaveLength(2)
unsubscribe()
} finally {
await bridge.close()
await rm(directory, { recursive: true, force: true })
}
await expect(fetch(`${bridge.connection.url}/state`)).rejects.toThrow()
})
})
+101
View File
@@ -9,6 +9,107 @@ bun install
bun dev
```
### Terminal app with the menu-bar companion
From the repository root, run:
```bash
bun run dev
```
The local V2 terminal app starts its service as usual, then automatically launches the bundled Electron tray entrypoint.
No desktop window or separately started server is required. New Agent and Settings act on the terminal app; session rows
select the corresponding TUI session. On macOS the companion also activates the originating terminal application when
its bundle identifier is available. Multiple terminal clients share one companion and their open sessions are combined.
The companion exits after the last terminal client exits; quitting the companion itself does not stop the TUI or server.
Opt out through **Open settings → Terminal → Menu bar / system tray**, or set `"tray": { "enabled": false }` in `cli.json`.
`OPENCODE_TRAY=0 bun run dev` disables the companion for one invocation. SSH, headless Linux, and non-interactive commands
do not launch it. The local launcher needs the normal workspace dependencies (`bun install`) and builds only the Electron
main process, not the desktop renderer. Launcher output is written to `terminal-tray.log` in the OpenCode log directory.
Packaged CLI distributions can supply an `opencode-tray` executable beside the CLI or set `OPENCODE_TRAY_EXECUTABLE` to
the companion launcher. The automatic workspace build path is specific to local source development.
### Menu bar / system tray scaffold
The desktop bundle includes a native tray companion, enabled by default. It offers **New Agent…**
(opens a new session draft), **Settings**, **Docs**, and **Quit**. Closing the last window keeps
the companion running; Quit exits the desktop process, not the shared OpenCode background service.
The icon appears as soon as Electron is ready, before shell-environment discovery and service initialization. Its startup
menu remains available while the full session menu loads, and the saved opt-out is still respected.
Above those actions, the tray shows the local-service sessions open in tabs across this desktop app's windows. It groups
them into **Needs you** and **Active sessions**, with titles, project directory names, and the last measured
context usage. Clicking a session focuses the window that already owns its tab. Duplicate tabs across windows appear once;
closed tabs, blank drafts, history-only sessions, and terminal-only sessions are excluded. All open session tabs are included,
not just the eight most recent sessions. Tab changes trigger a refresh; status and context refresh every ten seconds while
enabled. An open menu keeps its rows stable until dismissed. Connection failures are labeled and retain the last available
summary, but closing a tab still removes it.
Working sessions keep their existing position in Active sessions; their favicon is replaced with a simple working dot.
Context is the latest measured assistant step (not cumulative session tokens or task progress). If the model's context
limit is unavailable, the tray shows token usage instead; sessions without a recent measurement say **No context**.
The session's total recorded cost in USD follows the context, formatted using the active locale (for example, `Context 52% · $0.12`).
On macOS 14.4+, each session uses one native menu item with a title and secondary label. Both lines share the native hover
highlight and click target, with compact system spacing and default styling. Transparent padding below each session icon
positions the visible favicon alongside the title without changing its size, horizontal padding, or shared hover target.
Platforms without native secondary labels
retain separate title/detail rows and an invisible alignment spacer. Menu icons have an extra four-point trailing gutter.
Footer shortcuts use the operating system's separate, subdued shortcut column:
**Cmd/Ctrl+N** for New Agent, **Cmd/Ctrl+,** for Settings, and **Cmd/Ctrl+Q** for Quit. Docs has no shortcut.
**Cmd+Option+O** opens the tray globally on macOS (**Ctrl+Alt+O** on Windows/Linux), even while another app is focused.
It is registered when the tray icon appears and released when the tray is disabled or the app quits. If another application
already owns the combination, the tray remains usable by mouse and a shortcut-registration warning is written to the log.
Session titles are single-line and ellipsized. The project name on the detail line gets the remaining text budget after
context and cost, so those values stay visible. Full titles and directories remain available in the session tooltip.
Tooltips identify **Local** or **Worktree · name** using the server-resolved checkout root, followed by the full session
directory. Usage reads **Context 52%** and **Cost $0.12**. If placement resolution fails, the tooltip says **Location unavailable**.
Session icons use the project's favicon/override, or its colored initial when no image is available. They use
the same resolved workspace icon and theme colors published by their desktop tab, including the avatar gradient and border.
Project images remain full-color rather than template images. Working sessions use a static dot because native menus do
not reliably repaint animation frames. Finished sessions with a completion timestamp newer than their viewed timestamp
show a blue corner dot on the restored favicon, until the client acknowledges viewing that completion. Questions and
permission requests also add an attention dot. The menu-bar icon is badged whenever any listed session has one of these
notifications. State refreshes on the normal polling cycle; an open native menu keeps its current layout until reopened.
Footer actions reuse the app's SVG artwork,
with book and exit icons for Docs and Quit. SVG favicons are rasterized for native menus without opening a renderer window.
From the repository root:
```bash
bun run dev:tray # Start with only the menu bar / tray icon
bun run dev:desktop # Start the usual desktop window and the icon
bun run dev:tray:live # Use the installed opencode2 service for this desktop's tabs without replacing it
```
Or run `bun dev --tray` from `packages/desktop`. Packaged applications accept `--tray` as well. A tray-only launch respects
the saved opt-out and exits if the companion is disabled. Launch the desktop normally to enable it again under
**Settings → Preferences → Display → Menu bar / system tray**. The preference is shared between windows and persists
across launches. Development uses the dev app's settings, separate from the production app.
Manual checks:
- Start tray-only: the icon should appear without a desktop window.
- With no desktop windows open, the menu should say No active sessions. Open Settings or New Agent to restore a window.
- Verify that session rows appear above the actions and clicking one opens that exact session.
- Open and close session tabs: only open tabs should appear. Test multiple windows and a session open in both.
- Trigger a question or permission request: the session should move into Needs you on the next refresh.
- Choose New Agent or Settings: a window should open and perform the action after loading.
- Minimize that window and choose a session or Settings: it should restore and focus.
- Close all windows: the icon should remain available. Repeat New Agent and Settings.
- Open docs: the V2 documentation should open in your browser.
- Disable the tray in Settings: the icon should disappear immediately. Enable it again to restore it.
- Quit from the tray: the app and icon should exit. A subsequent launch should respect the saved preference.
Regular `dev:tray` uses the isolated desktop development server. `dev:tray:live` uses the client library to discover a
running service (no `opencode2` executable required), then attaches without electing or replacing it. These desktop-only
commands show desktop tabs; use `bun run dev` for the terminal companion. Selecting a TUI session updates the owning TUI,
but selecting a particular emulator tab/window among multiple Ghostty windows is not portable and remains host-dependent.
## Build
Run the `build` script to build the app's JS assets, then `package` to
@@ -99,6 +99,7 @@ test("bundles one Effect runtime and Drizzle while keeping native dependencies e
expect(new Set(effect.map((id) => id.split("/node_modules/effect/")[0])).size).toBe(1)
expect(new Set(effect).size).toBe(effect.length)
expect(imports).toContain("electron")
expect(imports).toContain("@resvg/resvg-js")
expect(imports).toContain("node:sqlite")
expect(chunks.some((chunk) => chunk.dynamicImports.includes("@zip.js/zip.js"))).toBe(true)
expect(imports).toContain(`@lydell/node-pty-${process.platform}-${process.arch}`)
+3 -1
View File
@@ -25,11 +25,13 @@
},
"main": "./out/main/index.js",
"dependencies": {
"@resvg/resvg-js": "2.6.2",
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1"
"lighthouse": "13.4.1",
"string-width": "catalog:"
},
"devDependencies": {
"@brendonovich/vite-plugin-opencode": "0.1.3",
+19
View File
@@ -0,0 +1,19 @@
import { Service } from "@opencode/client/service"
import { homedir } from "node:os"
import { join } from "node:path"
const endpoints = await Promise.all([
Service.discover(),
Service.discover({
file: join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode", "service-local.json"),
}),
])
const endpoint = endpoints.find((endpoint) => endpoint !== undefined)
if (!endpoint)
throw new Error(
"No running OpenCode service found. Use `bun run dev` from the repository root to start the terminal app with its tray companion.",
)
process.env.OPENCODE_DESKTOP_SERVER_URL = endpoint.url
process.env.OPENCODE_DESKTOP_SERVER_PASSWORD = endpoint.auth?.password ?? ""
process.argv.push("--tray")
await import("./dev")
@@ -0,0 +1,51 @@
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { spawn } from "node:child_process"
const root = fileURLToPath(new URL("..", import.meta.url))
const file = process.argv[2]
if (!file) throw new Error("A terminal tray connection file is required")
process.env.OPENCODE_CHANNEL = "local"
const install = Bun.spawn([process.execPath, "run", "install-electron"], {
cwd: root,
stdout: "inherit",
stderr: "inherit",
})
if (await install.exited) throw new Error("Could not prepare Electron")
const { loadConfigFromFile, MainConfigFactory } = await import("electron-vite")
const { build } = await import("vite")
const result = await loadConfigFromFile(
{ command: "build", mode: "development" },
join(root, "electron.vite.config.ts"),
)
if (!result.config.main) throw new Error("Missing Electron main configuration")
const config = await new MainConfigFactory(
result.config.main,
{ configFile: false, mode: "development" },
{ root },
).build()
config.build = {
...config.build,
outDir: join(root, "out/terminal-tray"),
emptyOutDir: false,
rolldownOptions: {
...config.build?.rolldownOptions,
input: { index: join(root, "src/main/terminal-tray-entry.ts") },
},
}
await build(config)
const { default: electron } = await import("electron")
const child = spawn(electron, [join(root, "out/terminal-tray/index.js"), `--terminal-tray=${file}`], {
cwd: root,
env: process.env,
detached: true,
stdio: "ignore",
windowsHide: true,
})
await new Promise<void>((resolve, reject) => {
child.once("spawn", resolve)
child.once("error", reject)
})
child.unref()
+3 -1
View File
@@ -29,13 +29,15 @@ async function prepareDesktop() {
function selectOptions(): DevOptions {
const args = process.argv.slice(2)
const tray = args.indexOf("--tray")
if (tray >= 0) process.env.OPENCODE_DESKTOP_TRAY_ONLY = "1"
const build = args.indexOf("--build-server")
const download = args.indexOf("--download-server")
if (build >= 0 && download >= 0) {
throw new Error("--build-server and --download-server cannot be used together")
}
if (download >= 0 && !args[download + 1]) throw new Error("--download-server requires a version")
const consumed = new Set([build, download, download >= 0 ? download + 1 : -1])
const consumed = new Set([build, download, download >= 0 ? download + 1 : -1, tray])
return {
server: download >= 0 ? { type: "download", version: args[download + 1] } : { type: "build" },
electron: args.filter((_, index) => !consumed.has(index)),
+4 -1
View File
@@ -10,6 +10,7 @@ import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
import { marks } from "./lifecycle/marks"
import { getTrayEnabled, trayOnly } from "./native/tray"
// Everything above has been loaded and evaluated by now; the layers start below.
marks.bundle = Date.now()
@@ -19,7 +20,8 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () {
marks.layers = Date.now()
yield* Effect.logInfo("layers ready", { marks })
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
ipc.installMenu()
if (!trayOnly()) lifecycle.restoreWindows()
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
yield* Effect.sleep("500 millis")
@@ -29,6 +31,7 @@ const runIpc = Effect.fn("Desktop.runIpc")(function* () {
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
if (trayOnly() && !getTrayEnabled()) app.quit()
return Effect.sync(() => app.off("will-quit", quit))
})
})
+2
View File
@@ -1,3 +1,5 @@
/// <reference types="electron-vite/node" />
interface ImportMetaEnv {
readonly OPENCODE_CHANNEL: string
readonly OPENCODE_VERSION?: string
+2 -1
View File
@@ -20,7 +20,8 @@ if (acquireApplicationLock()) {
void app.whenReady().then(async () => {
marks.ready = Date.now()
registerStorageSnapshotHandler()
createEarlyWindow()
// Tray-only launches (see native/tray.ts trayOnly) start without a window.
if (!process.argv.includes("--tray") && process.env.OPENCODE_DESKTOP_TRAY_ONLY !== "1") createEarlyWindow()
marks.window = Date.now()
startSidecarProbe()
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
@@ -22,6 +22,8 @@ import { DesktopStorage } from "../storage"
import { createPairing } from "../service/pairing"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
import { sender } from "./context"
import { getTrayEnabled, setTrayEnabled, updateTray } from "../native/tray"
import { trayTabs } from "../native/tray-tabs"
export const appHandlers = AppRpcs.toLayer(
Effect.gen(function* () {
@@ -37,6 +39,13 @@ export const appHandlers = AppRpcs.toLayer(
const pairing = createPairing()
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppGetTrayEnabled: () => Effect.sync(getTrayEnabled),
AppSetTrayEnabled: ({ enabled }) => Effect.sync(() => setTrayEnabled(enabled)),
AppSetTrayTabs: ({ sessionIDs, avatars }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win && !win.isDestroyed()) trayTabs.set(win.id, sessionIDs, avatars)
}),
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
AppReconnectService: () => background.reconnect.pipe(Effect.map(SidecarCredentials.ready)),
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
@@ -62,6 +71,7 @@ export const appHandlers = AppRpcs.toLayer(
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
if (!setNativeTranslations(bundle)) return
updateTray()
createMenu({
trigger: (id) => {
const win = getLastFocusedWindow()
@@ -6,6 +6,7 @@ import { ApplicationLifecycle } from "../lifecycle"
import { runDesktopMenuAction } from "../native/menu-actions"
import { Updater } from "../updater"
import { sender } from "./context"
import { menuCommands } from "../native/menu"
export const menuHandlers = MenuRpcs.toLayer(
Effect.gen(function* () {
@@ -14,6 +15,11 @@ export const menuHandlers = MenuRpcs.toLayer(
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
return MenuRpcs.of({
MenuReady: (_, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
return win ? menuCommands.ready(win) : []
}),
MenuRunAction: ({ action }, context) =>
Effect.sync(() =>
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
+83 -4
View File
@@ -1,7 +1,7 @@
export * as Ipc from "./ipc"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { Effect, Layer, Queue } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
@@ -19,7 +19,11 @@ import { Ssh } from "./ssh/service"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
import { ApplicationLifecycle } from "./lifecycle"
import { showCliInstaller } from "./native/install-cli"
import { createMenu, sendMenuCommand } from "./native/menu"
import { createMenu, menuCommands, sendMenuCommand } from "./native/menu"
import { getTrayEnabled, setTraySessions, startTray, syncTrayTabs } from "./native/tray"
import { trayTabs } from "./native/tray-tabs"
import { loadTraySessions } from "./native/tray-sessions"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
@@ -48,6 +52,7 @@ export const registerIpcHandlers = Effect.gen(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const desktopCli = yield* DesktopCli.Service
const updater = yield* Updater.Service
const background = yield* BackgroundService.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
trigger: (id: string) => {
@@ -61,8 +66,23 @@ export const registerIpcHandlers = Effect.gen(function* () {
relaunch: lifecycle.relaunch,
}
const wire = (_event: Electron.Event, win: BrowserWindow) => {
win.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.once("closed", () => trayTabs.remove(win.id))
win.webContents.on("did-start-loading", () => menuCommands.reset(win))
win.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown") return
if (
process.platform !== "darwin" &&
input.key.toLowerCase() === "q" &&
input.control &&
!input.meta &&
!input.alt &&
!input.shift
) {
event.preventDefault()
app.quit()
return
}
if (input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
const post = () => {
@@ -80,6 +100,65 @@ export const registerIpcHandlers = Effect.gen(function* () {
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
})
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
const { createTrayIcons } = yield* Effect.promise(() => import("./native/tray-icons"))
const icons = yield* Effect.promise(createTrayIcons)
const stopTray = startTray(
{
open: () => menuCommands.trigger(lifecycle.showWindow(), { type: "home" }),
trigger: (id) => sendMenuCommand(lifecycle.showWindow(), id),
session: (sessionID) => {
const owner = trayTabs.owner(sessionID, getLastFocusedWindow()?.id)
const win = owner === undefined ? undefined : BrowserWindow.fromId(owner)
if (!win) return
if (win.isMinimized()) win.restore()
win.show()
win.focus()
menuCommands.trigger(win, { type: "session", sessionID })
},
docs: () => menu.openExternal("https://opencode.ai/v2/docs/"),
quit: () => app.quit(),
},
icons,
)
yield* Effect.addFinalizer(() => Effect.sync(stopTray))
const refresh = yield* Queue.sliding<void>(1)
const unsubscribe = trayTabs.subscribe(() => {
syncTrayTabs()
Queue.offerUnsafe(refresh, undefined)
})
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
yield* Queue.offer(refresh, undefined)
yield* Effect.gen(function* () {
yield* Queue.take(refresh).pipe(Effect.timeoutOption("10 seconds"))
if (!getTrayEnabled()) return
const sessionIDs = trayTabs.sessions()
if (!sessionIDs.length) {
syncTrayTabs()
return
}
const connection = yield* background.connection
const { OpenCode } = yield* Effect.promise(() => import("@opencode/client"))
const client = OpenCode.make({
baseUrl: connection.url,
headers: connection.password
? { authorization: `Basic ${Buffer.from(`opencode:${connection.password}`).toString("base64")}` }
: undefined,
})
const snapshot = yield* Effect.tryPromise((signal) =>
loadTraySessions(client, sessionIDs, AbortSignal.any([signal, AbortSignal.timeout(15_000)])),
)
yield* Effect.promise((signal) =>
icons.prepare(
snapshot.sessions.map((session) => ({ ...session, avatar: trayTabs.avatar(session.id) })),
AbortSignal.any([signal, AbortSignal.timeout(5_000)]),
),
)
setTraySessions(snapshot)
}).pipe(
Effect.catch(() => Effect.sync(() => setTraySessions())),
Effect.forever,
Effect.forkScoped,
)
return {
installMenu: () => createMenu(menu),
}
@@ -3,6 +3,7 @@ export * as DesktopInitialization from "./desktop-initialization"
import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { initializeTray } from "../native/tray"
import { getStore } from "../storage/store"
import { marks } from "./marks"
import {
@@ -30,6 +31,8 @@ export const layer = Layer.effect(
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
)
yield* Effect.promise(() => app.whenReady())
const stopTray = initializeTray()
yield* Effect.addFinalizer(() => Effect.sync(stopTray))
yield* prepareDesktop
marks.init = Date.now()
return Service.of({
+17 -11
View File
@@ -13,6 +13,7 @@ import { marks } from "./marks"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
import { consoleReturnWindow } from "./deep-link"
import { hasTray } from "../native/tray"
export interface Interface {
readonly relaunch: () => void
@@ -20,6 +21,7 @@ export interface Interface {
readonly consumeInitialDeepLinks: () => string[]
readonly createWindow: () => BrowserWindow
readonly restoreWindows: () => BrowserWindow[]
readonly showWindow: () => BrowserWindow
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/ApplicationLifecycle") {}
@@ -32,6 +34,14 @@ const runtime = Layer.effect(
const windows = yield* makeMainWindows()
const createWindow = windows.create
const restoreWindows = windows.restore
const showWindow = () => {
const existing = getLastFocusedWindow()
if (!existing) return restoreWindows()[0] ?? createWindow()
if (existing.isMinimized()) existing.restore()
existing.show()
existing.focus()
return existing
}
const pendingDeepLinks: string[] = []
let shutdownReady = false
const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true))))
@@ -72,7 +82,8 @@ const runtime = Layer.effect(
runFork(Effect.logInfo("deep link received via second-instance", { urls }))
focusWindow(emitDeepLinks(urls) ?? null)
}
if (!urls.length) focusWindow(getLastFocusedWindow())
if (urls.length || argv.includes("--tray")) return
showWindow()
}
const openUrl = (event: Event, url: string) => {
event.preventDefault()
@@ -106,13 +117,12 @@ const runtime = Layer.effect(
runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit()))))
}
const windowAllClosed = () => {
if (process.platform !== "darwin") app.quit()
if (process.platform !== "darwin" && !hasTray()) app.quit()
}
const activate = () => {
if (BrowserWindow.getAllWindows().length === 0) restoreWindows()
}
const resetRelaunchHandler = setRelaunchHandler(relaunch)
let windowsWired = false
app.on("second-instance", secondInstance)
app.on("open-url", openUrl)
@@ -120,6 +130,8 @@ const runtime = Layer.effect(
app.on("will-quit", willQuit)
app.on("child-process-gone", childProcessGone)
app.on("render-process-gone", renderProcessGone)
app.on("window-all-closed", windowAllClosed)
app.on("activate", activate)
process.on("SIGINT", signal)
process.on("SIGTERM", signal)
yield* Effect.addFinalizer(() =>
@@ -143,14 +155,8 @@ const runtime = Layer.effect(
prepareToRestart,
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
createWindow,
restoreWindows: () => {
if (!windowsWired) {
windowsWired = true
app.on("window-all-closed", windowAllClosed)
app.on("activate", activate)
}
return restoreWindows()
},
showWindow,
restoreWindows,
})
}),
)
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { createMenuQueue } from "./menu-queue"
import type { MenuCommand } from "../../shared/menu-command"
describe("native menu command handoff", () => {
test("queues session navigation alongside ordinary commands on a cold window", () => {
const delivered: MenuCommand[] = []
const queue = createMenuQueue<object, MenuCommand>((_, command) => delivered.push(command))
const win = {}
queue.trigger(win, { type: "session", sessionID: "ses_from_terminal" })
queue.reset(win)
expect(queue.ready(win)).toEqual([{ type: "session", sessionID: "ses_from_terminal" }])
queue.trigger(win, { type: "command", id: "settings.open" })
expect(delivered).toEqual([{ type: "command", id: "settings.open" }])
})
test("holds commands until a newly opened window registers its commands", () => {
const delivered: string[] = []
const queue = createMenuQueue(() => delivered.push("unexpected"))
const win = {}
queue.trigger(win, "tab.new")
// Loading can begin after the native click creates the window.
queue.reset(win)
queue.trigger(win, "settings.open")
expect(delivered).toEqual([])
expect(queue.ready(win)).toEqual(["tab.new", "settings.open"])
expect(queue.ready(win)).toEqual([])
})
test("delivers immediately to a ready window and waits again after reload", () => {
const delivered: string[] = []
const queue = createMenuQueue((_, id) => delivered.push(id))
const win = {}
queue.ready(win)
queue.trigger(win, "settings.open")
expect(delivered).toEqual(["settings.open"])
queue.reset(win)
queue.trigger(win, "tab.new")
expect(delivered).toEqual(["settings.open"])
expect(queue.ready(win)).toEqual(["tab.new"])
})
test("keeps commands isolated between windows", () => {
const delivered: string[] = []
const queue = createMenuQueue((_, id) => delivered.push(id))
const first = {}
const second = {}
queue.ready(first)
queue.trigger(first, "tab.new")
queue.trigger(second, "settings.open")
expect(delivered).toEqual(["tab.new"])
expect(queue.ready(first)).toEqual([])
expect(queue.ready(second)).toEqual(["settings.open"])
})
})
@@ -0,0 +1,27 @@
// Native menu actions can create a window before its command registry exists.
// Hold those actions until the renderer explicitly claims them, not just until
// did-finish-load (which precedes application initialization).
export function createMenuQueue<Window extends object, Command = string>(
send: (win: Window, command: Command) => void,
) {
const windows = new WeakMap<Window, { ready: boolean; pending: Command[] }>()
return {
reset(win: Window) {
windows.set(win, { ready: false, pending: windows.get(win)?.pending ?? [] })
},
trigger(win: Window, id: Command) {
const state = windows.get(win)
if (state?.ready) return send(win, id)
if (state) {
state.pending.push(id)
return
}
windows.set(win, { ready: false, pending: [id] })
},
ready(win: Window) {
const state = windows.get(win)
windows.set(win, { ready: true, pending: [] })
return state?.pending ?? []
},
}
}
+7 -1
View File
@@ -12,6 +12,12 @@ import { emitIpcEvent } from "../ipc-events"
import { CHANNEL, UPDATER_ENABLED } from "../constants"
import { runDesktopMenuAction } from "./menu-actions"
import { nativeT } from "./translations"
import { createMenuQueue } from "./menu-queue"
import type { MenuCommand } from "../../shared/menu-command"
export const menuCommands = createMenuQueue<BrowserWindow, MenuCommand>((win, command) => {
emitIpcEvent(win.webContents, new MenuCommandTriggered({ command }))
})
type Deps = {
trigger: (id: string) => void
@@ -39,7 +45,7 @@ export function createMenu(deps: Deps) {
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
menuCommands.trigger(win, { type: "command", id })
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
@@ -2,6 +2,7 @@ import {
DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_KEYS,
formatDesktopNativeMessage,
formatDesktopNativeCost,
type DesktopNativeBundle,
type DesktopNativeKey,
} from "@opencode/app/i18n/desktop-native"
@@ -22,3 +23,7 @@ export function setNativeTranslations(next: DesktopNativeBundle) {
export function nativeT(key: DesktopNativeKey, params?: Record<string, string | number>) {
return formatDesktopNativeMessage(bundle.messages[key], params)
}
export function nativeCost(value: number) {
return formatDesktopNativeCost(bundle.locale, value)
}
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test"
import { Resvg } from "@resvg/resvg-js"
import { shellIcons } from "@opencode/ui/icons/shell"
import { getProjectAvatarSource } from "@opencode/app/project-avatar-source"
import { trayActionSvg, trayAppSvg, trayMenuIconSvg, traySessionSvg, trayStartupPixels } from "./tray-icon-art"
import { traySessionNotification, type TraySession } from "./tray-sessions"
const session: TraySession = {
id: "session",
directory: "/code/project",
status: "idle",
cost: 0,
project: { id: "project", name: "Dashboard", icon: { color: "blue" } },
}
function render(svg: string) {
return new Resvg(svg, { fitTo: { mode: "width", value: 36 }, font: { loadSystemFonts: false } }).render()
}
describe("tray icon artwork", () => {
test("renders the desktop's resolved avatar colors instead of the fallback palette", () => {
const avatar = {
name: "Workspace",
background: "#123456",
border: "#654321",
foreground: "#fefefe",
highlight: "#ffffff29",
accent: "#ff8800",
}
const artwork = traySessionSvg({ ...session, avatar, status: "permission" }, "black")
expect(artwork).toContain('fill="#123456"')
expect(artwork).toContain('stroke="#654321"')
expect(artwork).toContain('fill="#fefefe"')
expect(artwork).toContain('fill="#ff8800"')
expect(artwork).toContain("linearGradient")
expect(artwork).not.toContain('fill="#3250df"')
const image = render(artwork)
expect([...image.pixels.subarray((6 * 36 + 30) * 4, (6 * 36 + 30) * 4 + 4)]).toEqual([255, 136, 0, 255])
})
test("top-aligns session artwork using bottom padding without moving or resizing its pixels", () => {
const artwork = traySessionSvg({ ...session, status: "question" }, "black")
const options = { fitTo: { mode: "zoom" as const, value: 2 }, font: { loadSystemFonts: false } }
const normal = new Resvg(trayMenuIconSvg(artwork), options).render()
const twoLines = new Resvg(trayMenuIconSvg(artwork, true), options).render()
expect([twoLines.width, twoLines.height]).toEqual([44, 60])
expect(twoLines.pixels.subarray(0, normal.pixels.length)).toEqual(normal.pixels)
expect(twoLines.pixels.subarray(normal.pixels.length).every((value) => value === 0)).toBe(true)
})
test("centers the Quit artwork on the same axis as the other action icons", () => {
const image = new Resvg(trayActionSvg("quit", "black"), {
fitTo: { mode: "zoom", value: 8 },
font: { loadSystemFonts: false },
}).render()
const columns = Array.from({ length: image.width * image.height }, (_, index) => index)
.filter((index) => image.pixels[index * 4 + 3] > 128)
.map((index) => index % image.width)
expect((Math.min(...columns) + Math.max(...columns) + 1) / 2).toBe(image.width / 2)
})
test("adds a four-point icon-to-label gutter without shrinking the artwork", () => {
const artwork = trayActionSvg("settings", "black")
const image = new Resvg(trayMenuIconSvg(artwork), {
fitTo: { mode: "zoom", value: 2 },
font: { loadSystemFonts: false },
}).render()
const original = render(artwork)
expect([image.width, image.height]).toEqual([44, 36])
Array.from({ length: 36 }, (_, y) => y).forEach((y) => {
expect(image.pixels.subarray(y * 44 * 4, (y * 44 + 36) * 4)).toEqual(
original.pixels.subarray(y * 36 * 4, (y + 1) * 36 * 4),
)
expect(image.pixels.subarray((y * 44 + 36) * 4, (y + 1) * 44 * 4).every((value) => value === 0)).toBe(true)
})
})
test("reserves the same icon width for the detail line without drawing an icon", () => {
const image = new Resvg(trayMenuIconSvg(""), { fitTo: { mode: "zoom", value: 2 } }).render()
expect([image.width, image.height]).toEqual([44, 36])
expect(image.pixels.every((value) => value === 0)).toBe(true)
})
test("the immediate startup bitmap matches the fully initialized mark", () => {
expect(trayStartupPixels()).toEqual(render(trayAppSvg(false, "black")).pixels)
expect(trayStartupPixels(255)).toEqual(render(trayAppSvg(false, "white")).pixels)
})
test("reuses the app's exact new-session and settings SVG paths", () => {
expect(trayActionSvg("newAgent", "black")).toContain(shellIcons.edit.body)
expect(trayActionSvg("settings", "black")).toContain(shellIcons["settings-gear"].body)
for (const action of ["newAgent", "settings", "docs", "quit", "more"] as const) {
const image = render(trayActionSvg(action, "black"))
expect([image.width, image.height]).toEqual([36, 36])
expect(image.pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true)
}
})
test("puts the attention dot on the app mark and clears it when attention ends", () => {
const normal = render(trayAppSvg(false, "black"))
const attention = render(trayAppSvg(true, "black"))
const offset = (6 * 36 + 30) * 4
expect([...attention.pixels.subarray(offset, offset + 4)]).toEqual([59, 92, 246, 255])
expect(normal.pixels[offset + 3]).toBe(0)
expect(trayAppSvg(false, "white")).not.toContain("#3b5cf6")
expect(trayAppSvg(true, "white")).toContain('fill="white"')
})
test("uses project artwork for idle and attention states, a simple dot while running", () => {
const favicon = `data:image/png;base64,${render(trayActionSvg("docs", "red")).asPng().toString("base64")}`
expect(traySessionSvg(session, "black", favicon)).toContain(favicon)
expect(traySessionSvg(session, "black", favicon)).not.toContain("#3b5cf6")
for (const status of ["question", "permission"] as const) {
const svg = traySessionSvg({ ...session, status }, "black", favicon)
expect(svg).toContain(favicon)
expect(svg).toContain("#3b5cf6")
expect(svg).not.toContain('<circle cx="9" cy="9" r="3"')
}
const busy = traySessionSvg({ ...session, status: "working" }, "black", favicon)
expect(busy).toContain('<circle cx="9" cy="9" r="3" fill="black"/>')
expect(busy).not.toContain(favicon)
expect(busy).not.toContain("#3b5cf6")
expect(render(busy).pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true)
})
test("restores the favicon with a blue corner dot for an unviewed completion", () => {
const favicon = `data:image/png;base64,${render(trayActionSvg("docs", "red")).asPng().toString("base64")}`
const finished = { ...session, unread: true }
const artwork = traySessionSvg(finished, "black", favicon)
expect(artwork).toContain(favicon)
const image = render(artwork)
expect([...image.pixels.subarray((6 * 36 + 30) * 4, (6 * 36 + 30) * 4 + 4)]).toEqual([59, 92, 246, 255])
expect(traySessionNotification(finished)).toBe(true)
expect(traySessionNotification({ ...finished, unread: false })).toBe(false)
expect(traySessionNotification({ ...finished, status: "working" })).toBe(false)
expect(traySessionSvg({ ...finished, unread: false }, "black", favicon)).not.toContain("#3b5cf6")
})
test("keeps project overrides, color-only avatars, and the OpenCode favicon consistent with the app", () => {
expect(getProjectAvatarSource("project", { url: "icon.png", override: "custom.png" })).toBe("custom.png")
expect(getProjectAvatarSource("project", { url: "icon.png", color: "blue" })).toBeUndefined()
expect(getProjectAvatarSource("4b0ea68d7af9a6031a7ffda7ad66e0cb83315750")).toBe("https://opencode.ai/favicon.svg")
expect(traySessionSvg(session, "black")).toContain('fill="#3250df"')
expect(traySessionSvg({ ...session, project: { id: "project", name: "<example>" } }, "black")).toContain("&lt;")
expect(traySessionSvg({ ...session, project: { id: "project", name: "مشروع" } }, "black")).toContain("م")
})
})
@@ -0,0 +1,99 @@
import { shellIcons } from "@opencode/ui/icons/shell"
import { traySessionNotification, type TraySession } from "./tray-sessions"
export type TrayActionIcon = "newAgent" | "settings" | "docs" | "quit" | "more"
export function trayMenuIconSvg(artwork: string, twoLines = false) {
// Native menus measure the image's full width. Leave a trailing gutter
// without scaling the artwork or changing the menu-bar icon's size.
// For a title + sublabel, transparent space below the artwork offsets it
// toward the first line while AppKit centers the complete image canvas.
const height = twoLines ? 30 : 18
return `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="${height}" viewBox="0 0 22 ${height}">${artwork}</svg>`
}
export function trayStartupPixels(foreground = 0) {
// The initial mark needs no SVG renderer, fonts, assets, or server connection.
const pixels = Buffer.alloc(36 * 36 * 4)
Array.from({ length: 36 * 36 }, (_, index) => index).forEach((index) => {
const x = index % 36
const y = Math.floor(index / 36)
if (x < 6 || x >= 30 || y < 3 || y >= 32) return
if (x >= 12 && x < 24 && y >= 9 && y < 26) return
pixels.fill(foreground, index * 4, index * 4 + 3)
pixels[index * 4 + 3] = 255
})
return pixels
}
// Fallbacks while a desktop window has not published its resolved theme colors.
const avatarColors: Record<string, string> = {
orange: "#ee7330",
yellow: "#e7af36",
cyan: "#0096b8",
mint: "#0096b8",
green: "#2eaf5a",
lime: "#2eaf5a",
red: "#d92e3c",
pink: "#e4429e",
blue: "#3250df",
purple: "#623be2",
gray: "#5c5c5c",
}
export function trayActionSvg(action: TrayActionIcon, foreground: string) {
const name = {
newAgent: "edit",
settings: "settings-gear",
docs: "book-open",
quit: "log-out",
more: "grid-plus",
} as const
const icon = shellIcons[name[action]]
return svg(
`<svg x="1" y="1" width="16" height="16" viewBox="${icon.viewBox}" fill="none" color="${foreground}">${icon.body}</svg>`,
)
}
export function trayAppSvg(attention: boolean, foreground: string) {
// Preserve the 18-point tray icon and mark dimensions chosen in the scaffold.
return svg(badge(`<path d="M3 1.5H15V16H3ZM6 4.5V13H12V4.5Z" fill-rule="evenodd" fill="${foreground}"/>`, attention))
}
export function traySessionSvg(session: TraySession, foreground: string, image?: string) {
if (session.status === "working") {
return svg(`<circle cx="9" cy="9" r="3" fill="${foreground}"/>`)
}
const name =
session.avatar?.name ||
session.project?.name ||
session.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ||
""
const initial =
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(name)[Symbol.iterator]().next().value?.segment ??
""
const color = avatarColors[session.project?.icon?.color ?? "gray"] ?? avatarColors.gray
const background = escapeXml(session.avatar?.background || color)
const border = escapeXml(session.avatar?.border || color)
const text = escapeXml(session.avatar?.foreground || "white")
const highlight = escapeXml(session.avatar?.highlight || "#ffffff29")
const surface = `<rect x="1" y="1" width="16" height="16" rx="4" fill="${background}"/>`
const body = image
? `${surface}<defs><clipPath id="avatar"><rect x="1" y="1" width="16" height="16" rx="4"/></clipPath></defs><image href="${image}" x="1" y="1" width="16" height="16" preserveAspectRatio="xMidYMid slice" clip-path="url(#avatar)"/>`
: `${surface}<defs><linearGradient id="surface" x1="0" y1="0" x2="0" y2="1"><stop stop-color="${highlight}"/><stop offset="1" stop-color="${highlight}" stop-opacity="0"/></linearGradient></defs><rect x="1" y="1" width="16" height="16" rx="4" fill="url(#surface)"/><rect x="1.25" y="1.25" width="15.5" height="15.5" rx="3.75" fill="none" stroke="${border}" stroke-width="0.5"/><text x="9" y="12.5" text-anchor="middle" font-family="Arial" font-size="11" font-weight="530" fill="${text}">${escapeXml(initial.toLocaleUpperCase())}</text>`
return svg(badge(body, traySessionNotification(session), session.avatar?.accent))
}
function svg(body: string) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">${body}</svg>`
}
function badge(body: string, attention: boolean, accent = "#3b5cf6") {
if (!attention) return body
// A transparent clearance ring keeps the app's accent dot distinct on any menu background.
return `<defs><mask id="badge"><rect width="18" height="18" fill="white"/><circle cx="15" cy="3" r="4" fill="black"/></mask></defs><g mask="url(#badge)">${body}</g><circle cx="15" cy="3" r="2.75" fill="${escapeXml(accent)}"/>`
}
function escapeXml(value: string) {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;")
}
@@ -0,0 +1,69 @@
import { nativeImage, nativeTheme } from "electron"
import type { NativeImage } from "electron"
import { getProjectAvatarSource } from "@opencode/app/project-avatar-source"
import { trayActionSvg, trayAppSvg, trayMenuIconSvg, traySessionSvg, type TrayActionIcon } from "./tray-icon-art"
import type { TraySession } from "./tray-sessions"
export type TrayIcons = Awaited<ReturnType<typeof createTrayIcons>>
export async function createTrayIcons() {
const { Resvg } = await import("@resvg/resvg-js")
const images = new Map<string, string>()
const cache = new Map<string, NativeImage>()
const foreground = () => (nativeTheme.shouldUseDarkColors ? "#ffffff" : "#202020")
const render = (svg: string, template: boolean, text = false) => {
const key = `${template}:${svg}`
const existing = cache.get(key)
if (existing) return existing
const png = new Resvg(svg, { fitTo: { mode: "zoom", value: 2 }, font: { loadSystemFonts: text } }).render().asPng()
const image = nativeImage.createFromBuffer(png, { scaleFactor: 2 })
image.setTemplateImage(process.platform === "darwin" && template)
if (cache.size >= 128) cache.clear()
cache.set(key, image)
return image
}
return {
app: (attention: boolean) => render(trayAppSvg(attention, foreground()), !attention),
action: (action: TrayActionIcon) => render(trayMenuIconSvg(trayActionSvg(action, foreground())), true),
spacer: () => render(trayMenuIconSvg(""), false),
session: (session: TraySession, twoLines = false) => {
const image = images.get(source(session) ?? "")
return render(
trayMenuIconSvg(traySessionSvg(session, foreground(), image), twoLines),
session.status === "working",
!image && session.status !== "working",
)
},
async prepare(sessions: TraySession[], signal: AbortSignal) {
const sources = new Set(sessions.flatMap((session) => source(session) ?? []))
images.forEach((_, source) => {
if (!sources.has(source)) images.delete(source)
})
await Promise.all(
[...sources]
.filter((source) => !images.has(source))
.map(async (source) => {
// Project icons are images, never local file paths or authenticated API requests.
if (!/^(https?:\/\/|data:image\/)/i.test(source)) return
const image = await fetch(source, { signal })
.then(async (response) => {
if (!response.ok) return
const data = Buffer.from(await response.arrayBuffer())
const bitmap = nativeImage.createFromBuffer(data)
if (!bitmap.isEmpty()) return bitmap.resize({ width: 36 }).toDataURL()
const png = new Resvg(data, { fitTo: { mode: "width", value: 36 }, font: { loadSystemFonts: false } })
.render()
.asPng()
return `data:image/png;base64,${png.toString("base64")}`
})
.catch(() => undefined)
if (image) images.set(source, image)
}),
)
},
}
function source(session: TraySession) {
return session.avatar ? session.avatar.source : getProjectAvatarSource(session.project?.id, session.project?.icon)
}
}
@@ -0,0 +1,17 @@
import { expect, test } from "bun:test"
import { trayLabel, trayLabelWidth } from "./tray-label"
test("normalizes line breaks and whitespace before truncating", () => {
expect(trayLabel(" Fix\r\n login\t redirects\u2028now ")).toBe("Fix login redirects now")
expect(trayLabel("abcdefghijklmnop", 10)).toBe("abcdefghi…")
expect(trayLabel("short", 10)).toBe("short")
})
test("keeps Unicode graphemes intact and budgets for wide characters", () => {
expect(trayLabel("项目名称项目名称", 7)).toBe("项目名…")
expect(trayLabel("👩‍💻👩‍💻👩‍💻", 5)).toBe("👩‍💻👩‍💻…")
expect(trayLabel("e\u0301e\u0301e\u0301e\u0301", 3)).toBe("e\u0301e\u0301…")
expect(trayLabelWidth(trayLabel("项目名称".repeat(20)))).toBeLessThanOrEqual(44)
expect(trayLabel("long", 1)).toBe("…")
expect(trayLabel("long", 0)).toBe("")
})
@@ -0,0 +1,22 @@
import stringWidth from "string-width"
export const TRAY_LABEL_WIDTH = 44
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
export function trayLabelWidth(value: string) {
return stringWidth(value)
}
export function trayLabel(value: string, width = TRAY_LABEL_WIDTH) {
const line = value.replace(/\s+/gu, " ").trim()
if (stringWidth(line) <= width) return line
if (width <= 0) return ""
const prefix = Array.from(segmenter.segment(line)).reduce(
(result, item) => {
const length = result.width + stringWidth(item.segment)
return { width: length, text: length <= width - 1 ? result.text + item.segment : result.text }
},
{ width: 0, text: "" },
)
return `${prefix.text.trimEnd()}…`
}
@@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test"
import { trayMenu } from "./tray-menu"
import type { TraySessions } from "./tray-sessions"
import { TRAY_LABEL_WIDTH, trayLabelWidth } from "./tray-label"
const empty: TraySessions = { state: "ready", sessions: [], working: 0, attention: 0, more: false }
describe("tray menu", () => {
test("requests top-aligned icons only for native two-line items", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const snapshot: TraySessions = {
...empty,
sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }],
}
const icons = {
action: (name: string) => `${name}.png`,
session: (_session: unknown, twoLines: boolean) => (twoLines ? "top-aligned.png" : "normal.png"),
spacer: () => "transparent.png",
}
const native = trayMenu(actions, snapshot, icons, true)
const fallback = trayMenu(actions, snapshot, icons, false)
expect(native.find((item) => "id" in item && item.id === "session:session")?.icon).toBe("top-aligned.png")
expect(fallback.find((item) => "id" in item && item.id === "session:session")?.icon).toBe("normal.png")
})
test("truncates titles and project names while keeping context and cost on one line", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const title = "A very long session title\n".repeat(6)
const directory = `/code/${"项目名称".repeat(20)}`
const menu = trayMenu(actions, {
...empty,
sessions: [{ id: "long", title, directory, status: "idle", context: 52, cost: 12.34 }],
})
const index = menu.findIndex((item) => "id" in item && item.id === "session:long")
const session = menu[index]
const detail = "sublabel" in session ? session.sublabel : undefined
expect(session.label?.endsWith("…")).toBe(true)
expect(session.label).not.toMatch(/[\r\n\u2028\u2029]/u)
expect(trayLabelWidth(session.label ?? "")).toBeLessThanOrEqual(TRAY_LABEL_WIDTH)
expect(detail).toContain("… · Context 52% · $12.34")
expect(trayLabelWidth(detail ?? "")).toBeLessThanOrEqual(TRAY_LABEL_WIDTH)
expect("toolTip" in session && session.toolTip).toContain(title)
expect("toolTip" in session && session.toolTip).toContain(directory)
})
test("keeps View all sessions text-only", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const menu = trayMenu(
actions,
{ ...empty, more: true },
{
action: (name) => `${name}.png`,
session: (item) => `${item.id}.png`,
spacer: () => "transparent.png",
},
)
const item = menu.find((item) => item.label === "View all sessions…")
expect(item).toBeDefined()
expect(item && "icon" in item).toBe(false)
})
test("places an icon before every session and footer action", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const menu = trayMenu(
actions,
{ ...empty, sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }] },
{
action: (name) => `${name}.png`,
session: (item) => `${item.id}.png`,
spacer: () => "transparent.png",
},
)
expect(menu.filter((item) => item.click).map((item) => item.icon)).toEqual([
"session.png",
"newAgent.png",
"settings.png",
"docs.png",
"quit.png",
])
expect(menu.some((item) => item.label === "repo · No context · $0.00")).toBe(false)
})
test("provides real app actions without starting model execution", () => {
const actions: string[] = []
const menu = trayMenu(
{
open: () => actions.push("open"),
trigger: (id) => actions.push(id),
docs: () => actions.push("docs"),
quit: () => actions.push("quit"),
session: (id) => actions.push(id),
},
empty,
)
expect(menu.flatMap((item) => item.label ?? [])).toEqual([
"No active sessions",
"New Agent…",
"Settings",
"Docs",
"Quit",
])
menu.forEach((item) => item.click?.())
expect(actions).toEqual(["tab.new", "settings.open", "docs", "quit"])
})
test("shows the grouped summary before actions and opens the exact session", () => {
const opened: string[] = []
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session: (id: string) => opened.push(id) }
const snapshot: TraySessions = {
...empty,
attention: 1,
working: 1,
sessions: [
{
id: "question",
title: "Fix login",
directory: "/code/dashboard",
status: "question",
context: 52,
cost: 0.12,
placement: { type: "worktree", directory: "/code/worktrees/login-fix" },
},
{ id: "working", title: "CSV export", directory: "C:\\code\\api", status: "working", tokens: 1024, cost: 1.25 },
{
id: "idle",
directory: "/code/cli",
status: "idle",
cost: 0,
placement: { type: "local", directory: "/code/cli" },
},
],
}
const menu = trayMenu(actions, snapshot)
expect(menu.flatMap((item) => item.label ?? []).slice(0, 5)).toEqual([
"Needs you",
"Fix login",
"Active sessions",
"CSV export",
"Untitled session",
])
expect(menu.some((item) => item.label === "Working")).toBe(false)
const session = menu.find((item) => "id" in item && item.id === "session:question")
expect(session && "sublabel" in session && session.sublabel).toBe("dashboard · Context 52% · $0.12")
expect(session && "toolTip" in session && session.toolTip).toContain("Worktree · login-fix\n/code/dashboard")
expect(session && "toolTip" in session && session.toolTip).toContain("Context 52%\nCost $0.12")
expect(session && "toolTip" in session && session.toolTip).not.toContain("Context:")
session?.click?.()
expect(opened).toEqual(["question"])
expect(menu.some((item) => item.label === "dashboard · Context 52% · $0.12")).toBe(false)
const local = menu.find((item) => "id" in item && item.id === "session:idle")
expect(local && "toolTip" in local && local.toolTip).toContain("Local\n/code/cli")
})
test("retains aligned details where native sublabels are unavailable", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const menu = trayMenu(
actions,
{ ...empty, sessions: [{ id: "session", directory: "/repo", status: "idle", cost: 0 }] },
{
action: (name) => `${name}.png`,
session: () => "project.png",
spacer: () => "transparent.png",
},
false,
)
expect(menu.find((item) => item.label === "repo · No context · $0.00")?.icon).toBe("transparent.png")
})
test("uses the native shortcut column instead of adding shortcuts to action labels", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const menu = trayMenu(actions, empty)
expect(
menu
.filter((item) => item.click)
.map((item) => [item.label, "accelerator" in item ? item.accelerator : undefined]),
).toEqual([
["New Agent…", "CommandOrControl+N"],
["Settings", "CommandOrControl+,"],
["Docs", undefined],
["Quit", "CommandOrControl+Q"],
])
})
test("does not claim stale sessions are live after a connection failure", () => {
const actions = { open() {}, trigger() {}, docs() {}, quit() {}, session() {} }
const menu = trayMenu(actions, {
...empty,
state: "offline",
sessions: [{ id: "old", title: "Cached session", directory: "/repo", status: "working", cost: 0 }],
})
expect(menu[0].label).toBe("Unable to refresh sessions")
expect(menu[1].label).toBe("Showing the last available update")
expect(menu.some((item) => item.label === "New Agent…")).toBe(true)
expect(trayMenu(actions, { ...empty, state: "loading" })[0].label).toBe("Loading sessions…")
})
})
@@ -0,0 +1,106 @@
import type { MenuItemConstructorOptions } from "electron"
import { nativeCost, nativeT } from "./translations"
import { traySessionGroup, type TraySession, type TraySessions } from "./tray-sessions"
import type { TrayActionIcon } from "./tray-icon-art"
import { TRAY_LABEL_WIDTH, trayLabel, trayLabelWidth } from "./tray-label"
type TrayMenuIcons = {
action: (action: TrayActionIcon) => MenuItemConstructorOptions["icon"]
session: (session: TraySession, twoLines: boolean) => MenuItemConstructorOptions["icon"]
spacer: () => MenuItemConstructorOptions["icon"]
}
export type TrayActions = {
open: () => void
trigger: (id: string) => void
docs: () => void
quit: () => void
session: (id: string) => void
}
export function trayMenu(actions: TrayActions, snapshot: TraySessions, icons?: TrayMenuIcons, sublabels = true) {
const items = snapshot.sessions.flatMap((session, index) => {
const group = traySessionGroup(session.status)
const previous = snapshot.sessions[index - 1]
const project = session.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ?? session.directory
const context =
session.context !== undefined
? nativeT("desktop.tray.context.percent", { percent: session.context })
: session.tokens !== undefined
? nativeT("desktop.tray.context.tokens", { tokens: session.tokens })
: nativeT("desktop.tray.context.unknown")
const cost = nativeCost(session.cost)
const placement = !session.placement
? nativeT("desktop.tray.tooltip.placementUnknown")
: session.placement.type === "local"
? nativeT("desktop.tray.tooltip.local")
: nativeT("desktop.tray.tooltip.worktree", {
name:
session.placement.directory.replaceAll("\\", "/").split("/").filter(Boolean).at(-1) ??
session.placement.directory,
})
const remaining =
TRAY_LABEL_WIDTH - trayLabelWidth(nativeT("desktop.tray.session.detail", { project: "", context, cost }))
const detail = nativeT("desktop.tray.session.detail", {
project: trayLabel(project, remaining),
context,
cost,
})
return [
...(!previous || traySessionGroup(previous.status) !== group
? [{ label: nativeT(`desktop.tray.group.${group}`), enabled: false }]
: []),
{
id: `session:${session.id}`,
icon: icons?.session(session, sublabels),
label: trayLabel(session.title ?? "") || nativeT("desktop.tray.untitled"),
sublabel: sublabels ? detail : undefined,
toolTip: [
session.title,
placement,
session.directory,
session.detail,
session.model,
context,
nativeT("desktop.tray.tooltip.cost", { cost }),
]
.filter(Boolean)
.join("\n"),
click: () => actions.session(session.id),
},
// macOS sublabels belong to the same native item, including its hover
// highlight and click target. Retain detail rows on platforms without them.
...(sublabels ? [] : [{ label: detail, icon: icons?.spacer(), enabled: false }]),
]
})
return [
...(snapshot.state !== "ready" ? [{ label: nativeT(`desktop.tray.${snapshot.state}`), enabled: false }] : []),
...(snapshot.state === "offline" && snapshot.sessions.length
? [{ label: nativeT("desktop.tray.stale"), enabled: false }]
: []),
...items,
...(snapshot.state === "ready" && !items.length ? [{ label: nativeT("desktop.tray.empty"), enabled: false }] : []),
...(snapshot.more ? [{ label: nativeT("desktop.tray.more"), click: actions.open }] : []),
{ type: "separator" },
{
label: nativeT("desktop.tray.newAgent"),
icon: icons?.action("newAgent"),
accelerator: "CommandOrControl+N",
click: () => actions.trigger("tab.new"),
},
{
label: nativeT("desktop.menu.settings"),
icon: icons?.action("settings"),
accelerator: "CommandOrControl+,",
click: () => actions.trigger("settings.open"),
},
{ label: nativeT("desktop.tray.docs"), icon: icons?.action("docs"), click: actions.docs },
{ type: "separator" },
{
label: nativeT("desktop.tray.quit"),
icon: icons?.action("quit"),
accelerator: "CommandOrControl+Q",
click: actions.quit,
},
] satisfies MenuItemConstructorOptions[]
}
@@ -0,0 +1,246 @@
import { describe, expect, test } from "bun:test"
import { OpenCode, type SessionInfo, type SessionMessageAssistant } from "@opencode/client"
import { loadTraySessions } from "./tray-sessions"
const tokens = { input: 900, output: 100, reasoning: 10, cache: { read: 50, write: 40 } }
function session(id: string, updated: number, extra: Partial<SessionInfo> = {}): SessionInfo {
return {
id,
title: id,
projectID: "project",
location: { directory: "/code/project/src" },
cost: 4.56,
tokens: { ...tokens, input: 999_999 },
time: { created: 1, updated },
...extra,
}
}
// Exercise the generated client's real HTTP decoding as well as the tray's
// inventory logic, without running a provider or creating durable sessions.
function fixture(
input: {
many?: boolean
detailsUnavailable?: boolean
offline?: boolean
worktree?: boolean
placementUnavailable?: boolean
finished?: number
viewed?: number
running?: boolean
} = {},
) {
const requests: string[] = []
const inventory = input.many
? Array.from({ length: 12 }, (_, index) => session(`recent-${index}`, 100 - index))
: [
session("idle", 100, { outcome: "succeeded" }),
session("failed", 90, { outcome: "failed" }),
session("permission", 80),
session("question", 70),
session("archived", 60, { time: { created: 1, updated: 60, archived: 70 } }),
]
const message: SessionMessageAssistant = {
id: "message",
type: "assistant",
time: { created: 10, completed: 20 },
agent: "build",
model: { providerID: "provider", id: "model" },
content: [],
tokens,
}
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (input.offline) return new Response("Unavailable", { status: 503 })
if (url.pathname === "/api/project")
return Response.json([
{
id: "project",
canonical: "/code/another-clone",
name: "Dashboard",
icon: { url: "data:image/png;base64,fixture" },
},
])
if (url.pathname === "/api/location") {
if (input.placementUnavailable) return new Response("Unavailable", { status: 503 })
return Response.json({
directory: "/code/project/src",
project: {
id: "project",
directory: "/code/project",
canonical: input.worktree ? "/code/main" : "/code/project",
},
})
}
if (url.pathname === "/api/session") return Response.json({ data: inventory, cursor: {} })
if (url.pathname === "/api/session/active")
return Response.json({
data: {
older: { type: "running" },
permission: { type: "running" },
...(input.running ? { idle: { type: "running" } } : {}),
},
})
if (url.pathname === "/api/session/older")
return Response.json({ data: session("older", 2, { parentID: "parent" }) })
if (url.pathname === "/api/session/permission") return Response.json({ data: session("permission", 3) })
const found = inventory.find((item) => url.pathname === `/api/session/${item.id}`)
if (found)
return Response.json({
data: { ...found, time: { ...found.time, idle: input.finished, viewed: input.viewed } },
})
if (url.pathname.endsWith("/form"))
return Response.json({
data: url.pathname.includes("/question/")
? [{ id: "form", sessionID: "question", title: "Which approach?", fields: [] }]
: [],
})
if (url.pathname.endsWith("/permission"))
return Response.json({
data: url.pathname.includes("/permission/")
? [{ id: "permission", sessionID: "permission", action: "shell", resources: [] }]
: [],
})
if (url.pathname.endsWith("/message")) {
if (input.detailsUnavailable) return new Response("Unavailable", { status: 503 })
expect(url.searchParams.get("order")).toBe("desc")
expect(url.searchParams.get("limit")).toBe("10")
return Response.json({
data: [message, { ...message, id: "older-message", tokens: { ...tokens, input: 1 } }],
cursor: {},
})
}
if (url.pathname === "/api/model")
return Response.json({
location: {},
data: [{ id: "model", providerID: "provider", name: "Example Model", limit: { context: 2000 } }],
})
return new Response("Not found", { status: 404 })
},
})
return {
client: OpenCode.make({ baseUrl: server.url.toString() }),
requests,
[Symbol.dispose]() {
server.stop(true)
},
}
}
describe("tray session inventory", () => {
test.each([
{ finished: undefined, viewed: undefined, unread: false },
{ finished: 20, viewed: undefined, unread: true },
{ finished: 20, viewed: 10, unread: true },
{ finished: 20, viewed: 20, unread: false },
{ finished: 20, viewed: 30, unread: false },
{ finished: 20, viewed: 10, running: true, unread: false },
])("uses the completion/viewed watermarks for unread state: %j", async (input) => {
using server = fixture(input)
const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))
expect(result.sessions[0].unread).toBe(input.unread)
})
test("clears the completion badge on viewing and badges a later completion again", async () => {
const state: { finished: number; viewed?: number } = { finished: 20 }
using server = fixture(state)
const load = () => loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))
expect((await load()).sessions[0].unread).toBe(true)
state.viewed = 20
expect((await load()).sessions[0].unread).toBe(false)
state.finished = 30
expect((await load()).sessions[0].unread).toBe(true)
})
test("identifies worktrees by their resolved checkout root, including nested session directories", async () => {
using server = fixture({ worktree: true })
const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))
expect(result.sessions[0].directory).toBe("/code/project/src")
expect(result.sessions[0].placement).toEqual({ type: "worktree", directory: "/code/project" })
})
test("does not guess Local when placement cannot be resolved", async () => {
using server = fixture({ placementUnavailable: true })
const result = await loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))
expect(result.state).toBe("ready")
expect(result.sessions[0].placement).toBeUndefined()
})
test("loads open tabs only and prioritizes pending input over activity", async () => {
using server = fixture()
const result = await loadTraySessions(
server.client,
["idle", "failed", "permission", "question", "older", "archived"],
AbortSignal.timeout(5000),
)
expect(result.state).toBe("ready")
expect(result.sessions.map((item) => [item.id, item.status])).toEqual([
["permission", "permission"],
["question", "question"],
["idle", "idle"],
["failed", "failed"],
["older", "working"],
])
expect(result.attention).toBe(2)
expect(result.working).toBe(1)
expect(result.sessions[0].detail).toBe("shell")
expect(result.sessions[1].detail).toBe("Which approach?")
expect(result.sessions[0].tokens).toBe(1100)
expect(result.sessions[0].cost).toBe(4.56)
expect(result.sessions[0].placement).toEqual({ type: "local", directory: "/code/project" })
expect(result.sessions[0].context).toBe(55)
expect(result.sessions[0].model).toBe("Example Model")
expect(result.sessions[0].project).toMatchObject({
id: "project",
name: "Dashboard",
icon: { url: "data:image/png;base64,fixture" },
})
expect(server.requests.filter((path) => path === "/api/model")).toHaveLength(1)
expect(server.requests.filter((path) => path === "/api/location")).toHaveLength(1)
expect(server.requests.some((path) => path.includes("/archived/"))).toBe(false)
expect(server.requests).not.toContain("/api/session")
})
test("shows all open tabs, preserving their order within the active group", async () => {
using server = fixture({ many: true })
const ids = Array.from({ length: 12 }, (_, index) => `recent-${11 - index}`)
const result = await loadTraySessions(server.client, ids, AbortSignal.timeout(5000))
expect(result.sessions.map((item) => item.id)).toEqual(ids)
expect(result.more).toBe(false)
expect(result.working).toBe(0)
expect(server.requests).not.toContain("/api/session/older")
expect(server.requests).not.toContain("/api/session/permission")
})
test("does not fetch history or running sessions when there are no open tabs", async () => {
using server = fixture()
const result = await loadTraySessions(server.client, [], AbortSignal.timeout(5000))
expect(result.sessions).toEqual([])
expect(result.state).toBe("ready")
expect(server.requests).toEqual([])
})
test("deduplicates sessions open in multiple windows", async () => {
using server = fixture()
const result = await loadTraySessions(server.client, ["idle", "idle"], AbortSignal.timeout(5000))
expect(result.sessions.map((item) => item.id)).toEqual(["idle"])
expect(server.requests.filter((path) => path === "/api/session/idle")).toHaveLength(1)
})
test("keeps status when optional usage is unavailable and does not invent a percentage", async () => {
using server = fixture({ detailsUnavailable: true })
const result = await loadTraySessions(server.client, ["permission"], AbortSignal.timeout(5000))
expect(result.sessions[0].status).toBe("permission")
expect(result.sessions[0].tokens).toBeUndefined()
expect(result.sessions[0].cost).toBe(4.56)
expect(result.sessions[0].context).toBeUndefined()
})
test("fails the refresh instead of turning a disconnected server into an empty list", async () => {
using server = fixture({ offline: true })
await expect(loadTraySessions(server.client, ["idle"], AbortSignal.timeout(5000))).rejects.toThrow()
})
})
@@ -0,0 +1,150 @@
import type { LocationPublicInfo, OpenCode, ModelInfo, ProjectIcon, SessionInfo } from "@opencode/client"
import type { TrayAvatar } from "../../shared/tray-avatar"
export type TraySession = {
id: string
title?: string
directory: string
cost: number
status: "question" | "permission" | "working" | "failed" | "interrupted" | "idle"
detail?: string
tokens?: number
context?: number
model?: string
project?: { id: string; name?: string; icon?: ProjectIcon }
placement?: { type: "local" | "worktree"; directory: string }
avatar?: TrayAvatar
unread?: boolean
}
export type TraySessions = {
state: "loading" | "ready" | "offline"
sessions: TraySession[]
working: number
attention: number
more: boolean
}
export function traySessionGroup(status: TraySession["status"]) {
if (status === "question" || status === "permission") return "attention"
return "active"
}
export function traySessionNotification(session: TraySession) {
return (
session.status === "question" ||
session.status === "permission" ||
(session.status !== "working" && !!session.unread)
)
}
export async function loadTraySessions(
client: ReturnType<typeof OpenCode.make>,
sessionIDs: readonly string[],
signal: AbortSignal,
): Promise<TraySessions> {
if (!sessionIDs.length) return { state: "ready", sessions: [], working: 0, attention: 0, more: false }
const options = { signal }
const [tabs, active, projects] = await Promise.all([
Promise.all([...new Set(sessionIDs)].map((sessionID) => client.session.get({ sessionID }, options))),
client.session.active(options),
client.project.list(options).catch(() => []),
])
const sessions = tabs.filter((session) => !session.time.archived)
const byProject = new Map(projects.map((project) => [project.id, project]))
const roots = new Map<string, Promise<LocationPublicInfo | undefined>>()
const rows = await Promise.all(
sessions.map(async (session) => {
const key = JSON.stringify(session.location)
const root =
roots.get(key) ??
client.location.get({ location: { directory: session.location.directory } }, options).catch(() => undefined)
roots.set(key, root)
const [forms, permissions, current] = await Promise.all([
client.session.form.list({ sessionID: session.id }, options),
client.permission.list({ sessionID: session.id }, options),
root,
])
const status: TraySession["status"] = forms.length
? "question"
: permissions.length
? "permission"
: active[session.id]
? "working"
: session.outcome === "failed"
? "failed"
: session.outcome === "interrupted"
? "interrupted"
: "idle"
return {
session,
row: {
id: session.id,
title: session.title,
directory: session.location.directory,
cost: session.cost,
status,
unread:
!active[session.id] &&
session.time.idle !== undefined &&
(session.time.viewed === undefined || session.time.idle > session.time.viewed),
detail: forms[0]?.title ?? permissions[0]?.action,
project: byProject.get(session.projectID),
// Resolve the checkout root, not the session's subdirectory or the
// project-list canonical path (which may belong to another clone).
placement: current
? {
type: current.project.directory === current.project.canonical ? "local" : "worktree",
directory: current.project.directory,
}
: undefined,
} satisfies TraySession,
}
}),
)
const priority = { attention: 0, active: 1 }
rows.sort((a, b) => priority[traySessionGroup(a.row.status)] - priority[traySessionGroup(b.row.status)])
const catalogs = new Map<string, Promise<ModelInfo[]>>()
return {
state: "ready",
working: rows.filter((item) => item.row.status === "working").length,
attention: rows.filter((item) => traySessionGroup(item.row.status) === "attention").length,
more: false,
sessions: await Promise.all(
rows.map(async ({ session, row }): Promise<TraySession> => {
const messages = await client.message
.list({ sessionID: session.id, order: "desc", limit: 10 }, options)
.catch(() => undefined)
const message = messages?.data.find((item) => item.type === "assistant" && item.tokens)
if (message?.type !== "assistant" || !message.tokens) return row
const key = JSON.stringify(session.location)
const catalog = catalogs.get(key) ?? models(session)
catalogs.set(key, catalog)
const model = (await catalog).find(
(item) => item.id === message.model.id && item.providerID === message.model.providerID,
)
// Match the context indicator in the app: use the most recent measured
// assistant step, never the session's cumulative usage counters.
const tokens =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
return {
...row,
tokens,
context: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : undefined,
model: model?.name ?? message.model.id,
}
}),
),
}
function models(session: SessionInfo) {
return client.model
.list({ location: { directory: session.location.directory } }, options)
.then((result) => result.data)
.catch(() => [])
}
}
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut"
test("registers Command/Control+Alt+O and invokes the tray opener", () => {
const callbacks = new Map<string, () => void>()
const opened: string[] = []
const released: string[] = []
const shortcut = registerTrayShortcut(
{
register(key, callback) {
callbacks.set(key, callback)
return true
},
unregister(key) {
released.push(key)
callbacks.delete(key)
},
},
() => {
opened.push("tray")
},
)
expect(TRAY_SHORTCUT).toBe("CommandOrControl+Alt+O")
expect(shortcut.registered).toBe(true)
callbacks.get(TRAY_SHORTCUT)?.()
expect(opened).toEqual(["tray"])
shortcut.dispose()
shortcut.dispose()
expect(released).toEqual([TRAY_SHORTCUT])
expect(shortcut.registered).toBe(false)
expect(callbacks.size).toBe(0)
})
test("does not unregister a shortcut it could not acquire", () => {
const released: string[] = []
const shortcut = registerTrayShortcut(
{
register: () => false,
unregister: (key) => {
released.push(key)
},
},
() => {},
)
expect(shortcut.registered).toBe(false)
shortcut.dispose()
expect(released).toEqual([])
})
@@ -0,0 +1,18 @@
export const TRAY_SHORTCUT = "CommandOrControl+Alt+O"
export function registerTrayShortcut(
registry: { register: (key: string, callback: () => void) => boolean; unregister: (key: string) => void },
open: () => void,
) {
const state = { registered: registry.register(TRAY_SHORTCUT, open) }
return {
get registered() {
return state.registered
},
dispose() {
if (!state.registered) return
registry.unregister(TRAY_SHORTCUT)
state.registered = false
},
}
}
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { createTrayTabs } from "./tray-tabs"
import type { TrayAvatar } from "../../shared/tray-avatar"
describe("tray tab inventory", () => {
test("publishes avatar changes even when the same tabs remain open", () => {
const tabs = createTrayTabs()
const avatar: TrayAvatar = {
name: "Project",
background: "#263fa9",
border: "#7698fd",
foreground: "#fff",
highlight: "#ffffff29",
accent: "#3b5cf6",
}
const changes: string[] = []
tabs.subscribe(() => changes.push(tabs.avatar("a")?.background ?? "none"))
tabs.set(1, ["a"], { a: avatar })
tabs.set(1, ["a"], { a: { ...avatar } })
tabs.set(1, ["a"], { a: { ...avatar, background: "#3250df" } })
expect(changes).toEqual(["#263fa9", "#3250df"])
tabs.remove(1)
expect(tabs.avatar("a")).toBeUndefined()
})
test("deduplicates open sessions across windows and preserves tab order", () => {
const tabs = createTrayTabs()
tabs.set(1, ["b", "a", "b"])
tabs.set(2, ["a", "c"])
expect(tabs.sessions()).toEqual(["b", "a", "c"])
expect(tabs.owner("a", 2)).toBe(2)
expect(tabs.owner("b", 2)).toBe(1)
})
test("removes closed tabs and windows without removing a tab still open elsewhere", () => {
const tabs = createTrayTabs()
tabs.set(1, ["a", "b"])
tabs.set(2, ["b", "c"])
tabs.set(1, ["b"])
expect(tabs.sessions()).toEqual(["b", "c"])
expect(tabs.owner("a")).toBeUndefined()
tabs.remove(1)
expect(tabs.sessions()).toEqual(["b", "c"])
tabs.remove(2)
expect(tabs.sessions()).toEqual([])
})
test("notifies only when the inventory changes and releases subscribers", () => {
const tabs = createTrayTabs()
const changes: string[][] = []
const unsubscribe = tabs.subscribe(() => changes.push(tabs.sessions()))
tabs.set(1, ["a"])
tabs.set(1, ["a"])
tabs.set(1, ["b"])
tabs.remove(1)
tabs.remove(1)
unsubscribe()
tabs.set(2, ["c"])
expect(changes).toEqual([["a"], ["b"], []])
})
})
@@ -0,0 +1,38 @@
import type { TrayAvatar } from "../../shared/tray-avatar"
// Tabs belong to client windows, not to the server's session history.
export function createTrayTabs() {
const windows = new Map<number, { ids: readonly string[]; avatars: Record<string, TrayAvatar> }>()
const listeners = new Set<() => void>()
return {
set(windowID: number, sessionIDs: readonly string[], avatars: Record<string, TrayAvatar> = {}) {
const next = { ids: [...new Set(sessionIDs)], avatars }
const previous = windows.get(windowID)
if (JSON.stringify(previous) === JSON.stringify(next)) return
windows.set(windowID, next)
listeners.forEach((listener) => listener())
},
remove(windowID: number) {
if (windows.delete(windowID)) listeners.forEach((listener) => listener())
},
sessions() {
return [...new Set([...windows.values()].flatMap((window) => window.ids))]
},
owner(sessionID: string, preferred?: number) {
if (preferred !== undefined && windows.get(preferred)?.ids.includes(sessionID)) return preferred
return [...windows].find(([, window]) => window.ids.includes(sessionID))?.[0]
},
avatar(sessionID: string) {
return [...windows.values()].find((window) => window.ids.includes(sessionID) && window.avatars[sessionID])
?.avatars[sessionID]
},
subscribe(listener: () => void) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
export const trayTabs = createTrayTabs()
+145
View File
@@ -0,0 +1,145 @@
import { globalShortcut, Menu, nativeImage, nativeTheme, Tray } from "electron"
import type { NativeImage } from "electron"
import log from "electron-log/main.js"
import { TRAY_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { nativeT } from "./translations"
import { trayMenu, type TrayActions } from "./tray-menu"
import { traySessionNotification, type TraySessions } from "./tray-sessions"
import type { TrayIcons } from "./tray-icons"
import { trayTabs } from "./tray-tabs"
import { trayStartupPixels } from "./tray-icon-art"
import { registerTrayShortcut, TRAY_SHORTCUT } from "./tray-shortcut"
let tray: Tray | undefined
let actions: TrayActions | undefined
let icons: TrayIcons | undefined
let snapshot: TraySessions = { state: "loading", sessions: [], working: 0, attention: 0, more: false }
let menuOpen = false
let shortcut: ReturnType<typeof registerTrayShortcut> | undefined
export function initializeTray() {
if (tray || !getTrayEnabled()) return () => {}
const image = nativeImage.createFromBitmap(trayStartupPixels(nativeTheme.shouldUseDarkColors ? 255 : 32), {
width: 36,
height: 36,
scaleFactor: 2,
})
image.setTemplateImage(process.platform === "darwin")
tray = createTray(image)
tray.setToolTip(nativeT("desktop.menu.app"))
const menu = Menu.buildFromTemplate([
{ label: nativeT("desktop.tray.starting"), enabled: false },
{ type: "separator" },
{ label: nativeT("desktop.tray.quit"), role: "quit" },
])
menu.on("menu-will-show", () => {
menuOpen = true
})
menu.on("menu-will-close", () => {
menuOpen = false
})
tray.setContextMenu(menu)
return destroyTray
}
export function setTraySessions(next?: TraySessions) {
const current = next ?? { ...snapshot, state: "offline" as const }
const tabs = new Set(trayTabs.sessions())
const sessions = current.sessions
.filter((session) => tabs.has(session.id))
.map((session) => ({ ...session, avatar: trayTabs.avatar(session.id) }))
// Closing a tab takes effect immediately, including when an older poll is
// still in flight. It must not put a closed session back into the menu.
snapshot = {
...current,
state: tabs.size ? current.state : "ready",
sessions,
working: sessions.filter((session) => session.status === "working").length,
attention: sessions.filter((session) => session.status === "question" || session.status === "permission").length,
}
if (icons) tray?.setImage(icons.app(snapshot.sessions.some(traySessionNotification)))
// Don't move rows underneath the pointer while a native menu is open.
if (!menuOpen) updateTray()
}
export function syncTrayTabs() {
setTraySessions(snapshot)
}
export function getTrayEnabled() {
return getStore().get(TRAY_ENABLED_KEY) !== false
}
export function hasTray() {
return tray !== undefined
}
export function setTrayEnabled(enabled: boolean) {
getStore().set(TRAY_ENABLED_KEY, enabled)
updateTray()
}
export function startTray(next: TrayActions, artwork: TrayIcons) {
actions = next
icons = artwork
updateTray()
nativeTheme.on("updated", updateTray)
return () => {
nativeTheme.off("updated", updateTray)
destroyTray()
actions = undefined
icons = undefined
menuOpen = false
}
}
export function updateTray() {
if (!actions || !icons) return
if (!getTrayEnabled()) {
destroyTray()
return
}
if (!tray) tray = createTray(icons.app(snapshot.sessions.some(traySessionNotification)))
tray.setImage(icons.app(snapshot.sessions.some(traySessionNotification)))
tray.setToolTip(
snapshot.state === "ready"
? nativeT("desktop.tray.summary", { working: snapshot.working, attention: snapshot.attention })
: nativeT(`desktop.tray.${snapshot.state}`),
)
const version = process.platform === "darwin" ? process.getSystemVersion().split(".").map(Number) : []
const sublabels = process.platform === "darwin" && (version[0] > 14 || (version[0] === 14 && version[1] >= 4))
const menu = Menu.buildFromTemplate(trayMenu(actions, snapshot, icons, sublabels))
menu.on("menu-will-show", () => {
menuOpen = true
})
menu.on("menu-will-close", () => {
menuOpen = false
// Replacing a menu during its close event can swallow its selected action.
setImmediate(() => updateTray())
})
tray.setContextMenu(menu)
}
export function trayOnly() {
return process.argv.includes("--tray") || process.env.OPENCODE_DESKTOP_TRAY_ONLY === "1"
}
function createTray(image: NativeImage) {
const item = new Tray(image)
if (process.platform === "win32") item.on("click", () => item.popUpContextMenu())
shortcut = registerTrayShortcut(globalShortcut, () => {
if (!menuOpen) item.popUpContextMenu()
})
if (!shortcut.registered)
log.warn("Unable to register tray shortcut; it may be in use by another application", { shortcut: TRAY_SHORTCUT })
return item
}
function destroyTray() {
shortcut?.dispose()
shortcut = undefined
tray?.destroy()
tray = undefined
menuOpen = false
}
@@ -28,6 +28,13 @@ export const layer = Layer.effect(
)
const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial" | "reconnect") {
// Explicit development connections never elect or replace a managed server.
if (!app.isPackaged && process.env.OPENCODE_DESKTOP_SERVER_URL) {
return {
url: new URL(process.env.OPENCODE_DESKTOP_SERVER_URL).origin,
password: process.env.OPENCODE_DESKTOP_SERVER_PASSWORD ?? null,
}
}
yield* Effect.logInfo("starting v2 background service")
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
@@ -7,3 +7,4 @@ export const KEEP_SCREEN_ACTIVE_KEY = "keepScreenActive"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
export const TRAY_ENABLED_KEY = "trayEnabled"
@@ -0,0 +1,7 @@
import { app } from "electron"
import { runTerminalTray } from "./terminal-tray"
void runTerminalTray().catch(() => {
console.error("Could not start terminal tray companion")
app.exit(1)
})
+184
View File
@@ -0,0 +1,184 @@
import { app, Menu, shell } from "electron"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/service"
import { TerminalTray } from "@opencode/schema/terminal-tray"
import { Schema } from "effect"
import { readFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { createTrayIcons } from "./native/tray-icons"
import { getTrayEnabled, initializeTray, setTrayEnabled, setTraySessions, startTray } from "./native/tray"
import { loadTraySessions, type TraySession } from "./native/tray-sessions"
import { trayTabs } from "./native/tray-tabs"
const decodeConnection = Schema.decodeUnknownSync(Schema.fromJsonString(TerminalTray.Connection))
const decodeSnapshot = Schema.decodeUnknownSync(TerminalTray.Snapshot)
type Terminal = {
owner: number
connection: TerminalTray.Connection
snapshot?: TerminalTray.Snapshot
rows: TraySession[]
loaded: number
signature?: string
changed: number
offline: boolean
}
export async function runTerminalTray() {
app.setName("OpenCode")
app.setPath(
"userData",
process.env.OPENCODE_DESKTOP_TEST_ROOT
? join(process.env.OPENCODE_DESKTOP_TEST_ROOT, "terminal-tray")
: join(app.getPath("appData"), app.isPackaged ? "ai.opencode.terminal-tray" : "ai.opencode.terminal-tray.dev"),
)
await mkdir(app.getPath("userData"), { recursive: true })
if (!app.requestSingleInstanceLock()) {
app.quit()
return
}
const terminals = new Map<string, Terminal>()
const controller = new AbortController()
const runtime = { owner: -1, timer: undefined as ReturnType<typeof setTimeout> | undefined }
const attach = async (argv: string[]) => {
const file = argv.find((arg) => arg.startsWith("--terminal-tray="))?.slice("--terminal-tray=".length)
if (!file) return
const connection = decodeConnection(await readFile(file, "utf8"))
if (terminals.has(connection.id)) return
terminals.set(connection.id, {
owner: runtime.owner--,
connection,
rows: [],
loaded: 0,
changed: Date.now(),
offline: false,
})
}
app.on("second-instance", (_event, argv) => {
void attach(argv).catch(() => console.warn("Could not attach terminal tray connection"))
})
await attach(process.argv)
if (!terminals.size) {
app.quit()
return
}
await app.whenReady()
if (process.platform === "darwin") app.dock?.hide()
Menu.setApplicationMenu(null)
// The terminal's tray.enabled preference owns opt-out in this mode.
setTrayEnabled(true)
const stopInitial = initializeTray()
const icons = await createTrayIcons()
const active = () =>
[...terminals.values()]
.filter((terminal) => terminal.snapshot?.enabled)
.sort((a, b) => Number(b.snapshot?.focused) - Number(a.snapshot?.focused) || b.changed - a.changed)
const send = (command: TerminalTray.Command, terminal = active()[0]) => {
if (!terminal) return
void fetch(`${terminal.connection.url}/command`, {
method: "POST",
headers: { authorization: `Bearer ${terminal.connection.token}`, "content-type": "application/json" },
body: JSON.stringify(command),
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]),
}).catch(() => console.warn("Could not deliver terminal tray action"))
}
const stop = startTray(
{
open: () => send({ type: "focus" }),
trigger: (id) => send({ type: id === "tab.new" ? "new" : "settings" }),
session: (key) => {
const terminal = active().find((terminal) => terminal.rows.some((row) => row.id === key))
if (!terminal?.snapshot) return
send({ type: "session", sessionID: key.slice(terminal.snapshot.server.url.length + 1) }, terminal)
},
docs: () => {
void shell.openExternal("https://opencode.ai/v2/docs/")
},
quit: () => app.quit(),
},
icons,
)
app.on("before-quit", () => {
controller.abort()
clearTimeout(runtime.timer)
stop()
stopInitial()
})
const poll = async () => {
await Promise.all(
[...terminals].map(async ([id, terminal]) => {
const snapshot = await fetch(`${terminal.connection.url}/state`, {
headers: { authorization: `Bearer ${terminal.connection.token}` },
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]),
})
.then(async (response) => {
if (!response.ok) throw new Error("Terminal unavailable")
return decodeSnapshot(await response.json())
})
.catch(() => undefined)
if (!snapshot) {
terminals.delete(id)
trayTabs.remove(terminal.owner)
return
}
if (snapshot.current !== terminal.snapshot?.current || snapshot.focused !== terminal.snapshot?.focused)
terminal.changed = Date.now()
terminal.snapshot = snapshot
const keys = snapshot.enabled ? snapshot.sessions.map((id) => `${snapshot.server.url}\n${id}`) : []
trayTabs.set(terminal.owner, keys)
terminal.rows = terminal.rows.filter((row) => keys.includes(row.id))
if (!snapshot.enabled) return
const signature = JSON.stringify([snapshot.server, snapshot.sessions])
if (signature === terminal.signature && Date.now() - terminal.loaded < 10_000) return
terminal.signature = signature
terminal.loaded = Date.now()
const client = OpenCode.make({ baseUrl: snapshot.server.url, headers: Service.headers(snapshot.server) })
const result = await loadTraySessions(
client,
snapshot.sessions,
AbortSignal.any([controller.signal, AbortSignal.timeout(10_000)]),
).catch(() => undefined)
terminal.offline = !result
if (result)
terminal.rows = result.sessions.map((session) => ({
...session,
id: `${snapshot.server.url}\n${session.id}`,
}))
}),
)
if (controller.signal.aborted) return
if (!terminals.size) {
app.quit()
return
}
const enabled = active()
if (!enabled.length) {
app.quit()
return
}
if (getTrayEnabled() !== !!enabled.length) setTrayEnabled(!!enabled.length)
const sessions = [...new Map(enabled.flatMap((terminal) => terminal.rows).map((row) => [row.id, row])).values()]
sessions.sort(
(a, b) =>
Number(b.status === "permission" || b.status === "question") -
Number(a.status === "permission" || a.status === "question"),
)
await icons.prepare(sessions, AbortSignal.any([controller.signal, AbortSignal.timeout(3000)]))
if (controller.signal.aborted) return
setTraySessions({
state: enabled.some((terminal) => terminal.offline) ? "offline" : "ready",
sessions,
attention: 0,
working: 0,
more: false,
})
}
const refresh = () => {
void poll()
.catch(() => console.warn("Could not refresh terminal tray"))
.finally(() => {
if (!controller.signal.aborted) runtime.timer = setTimeout(refresh, 1000)
})
}
refresh()
}
+7 -1
View File
@@ -7,6 +7,8 @@ import type { SshPlatform } from "@opencode/app/ssh"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type { MenuCommand } from "../shared/menu-command"
import type { TrayAvatar } from "../shared/tray-avatar"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -59,7 +61,11 @@ export type ElectronAPI = {
getWindowID(): string
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onMenuCommand(cb: (command: MenuCommand) => void): () => void
menuReady(): Promise<MenuCommand[]>
getTrayEnabled(): Promise<boolean>
setTrayEnabled(enabled: boolean): Promise<void>
setTrayTabs(sessionIDs: string[], avatars?: Record<string, TrayAvatar>): Promise<void>
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
+5 -1
View File
@@ -126,7 +126,11 @@ export const api: ElectronAPI = {
getWindowID: () => window.electron.windowID,
getWindowBootstrap: () => window.electron.bootstrap,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.command)),
menuReady: () => invoke("MenuReady").then(mutable),
getTrayEnabled: () => invoke("AppGetTrayEnabled"),
setTrayEnabled: (enabled) => invoke("AppSetTrayEnabled", { enabled }),
setTrayTabs: (sessionIDs, avatars) => invoke("AppSetTrayTabs", { sessionIDs, avatars }),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable),
+63 -5
View File
@@ -5,12 +5,18 @@ import {
AppBaseProviders,
AppInterface,
currentRoute,
displayName,
getProjectAvatarVariant,
PlatformProvider,
preloadRoute,
projectForSession,
ServerConnection,
sessionHref,
useCommand,
useCurrentRoute,
useLanguage,
useServerCtx,
useServers,
useTabs,
useWslServers,
useSsh,
@@ -19,12 +25,17 @@ import {
} from "@opencode/app/desktop"
import { useTheme } from "@opencode/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import type { ElectronAPI } from "./api-types"
import type { MenuCommand } from "../shared/menu-command"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
import { traySessionIDs } from "./platform/tray-tabs"
import { trayAvatar } from "./platform/tray-avatar"
import { getProjectAvatarSource } from "@opencode/app/project-avatar-source"
import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization"
import { preloadStoredLocale } from "./startup/locale"
import { LoadingSplash } from "./startup/splash"
@@ -132,7 +143,7 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<DesktopEffects api={props.api} ready={startup.onboardingReady} />
<Suspense fallback={null}>
<Show when={initializationData(sidecar)} keyed>
{(server) => <MigrationStatus server={server} />}
@@ -195,11 +206,58 @@ function DesktopStartupReady(props: {
return null
}
function DesktopEffects(props: { api: ElectronAPI }) {
const command = useCommand()
bindDesktopMenu((id) => command.trigger(id))
function DesktopEffects(props: { api: ElectronAPI; ready: boolean }) {
const tabs = useTabs()
const theme = useTheme()
const servers = useServers()
const server = useServerCtx(() => servers.list.find(ServerConnection.builtin))
createEffect(() => {
if (!tabs.ready()) return
theme.themeId()
theme.mode()
const ids = traySessionIDs(tabs.store)
const ctx = server()
const style = getComputedStyle(document.documentElement)
const avatars = Object.fromEntries(
ids.flatMap((id) => {
const session = ctx?.data.session.get(id)
if (!session) return []
const project = projectForSession(session, ctx?.projects.list() ?? [])
const avatar = trayAvatar(
{
name: displayName(project ?? { worktree: session.location.directory }),
source: getProjectAvatarSource(project?.id, project?.icon),
variant: getProjectAvatarVariant(project?.icon?.color),
},
style,
)
return avatar ? [[id, avatar] as const] : []
}),
)
void props.api.setTrayTabs(ids, avatars).catch(console.error)
})
const command = useCommand()
const navigate = useNavigate()
const dispatch = (action: MenuCommand) => {
if (action.type === "command") return command.trigger(action.id)
if (action.type === "home") return navigate("/")
navigate(sessionHref(ServerConnection.Key.make("sidecar"), action.sessionID))
}
bindDesktopMenu(dispatch)
const menu = { ready: false }
createEffect(() => {
if (
!props.ready ||
menu.ready ||
!["tab.new", "settings.open"].every((id) => command.options.some((entry) => entry.id === id))
)
return
menu.ready = true
void props.api
.menuReady()
.then((commands) => commands.forEach(dispatch))
.catch(console.error)
})
createEffect(() => {
theme.themeId()
theme.mode()
@@ -96,6 +96,8 @@ export function createDesktopPlatform(
webviewZoom,
windowFullscreen,
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
getTrayEnabled: () => api.getTrayEnabled(),
setTrayEnabled: (enabled) => api.setTrayEnabled(enabled),
setPinchZoomEnabled,
getKeepScreenActive: () => api.getKeepScreenActive(),
setKeepScreenActive: (enabled) => api.setKeepScreenActive(enabled),
@@ -1,14 +1,15 @@
import type { Platform } from "@opencode/app/desktop"
import type { ElectronAPI } from "../api-types"
import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
import type { MenuCommand } from "../../shared/menu-command"
let trigger: ((id: string) => void) | null = null
let trigger: ((command: MenuCommand) => void) | null = null
export function startDesktopMenu(api: ElectronAPI) {
api.onMenuCommand((id) => trigger?.(id))
}
export function bindDesktopMenu(next: (id: string) => void) {
export function bindDesktopMenu(next: (command: MenuCommand) => void) {
trigger = next
}
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { trayAvatar } from "./tray-avatar"
test("uses resolved desktop colors and preserves a workspace-specific favicon", () => {
const values: Record<string, string> = {
"--v2-avatar-bg-blue": " #263fa9ff ",
"--v2-avatar-border-blue": "#7698fdff",
"--v2-avatar-fg": "#ffffffff",
"--v2-alpha-light-16": "rgba(255, 255, 255, 0.16)",
"--v2-background-bg-accent": "#3b5cf6ff",
}
expect(
trayAvatar(
{ name: "Workspace", source: "data:image/png;base64,workspace", variant: "blue" },
{
getPropertyValue: (name) => values[name] ?? "",
},
),
).toEqual({
name: "Workspace",
source: "data:image/png;base64,workspace",
background: "#263fa9ff",
border: "#7698fdff",
foreground: "#ffffffff",
highlight: "rgba(255, 255, 255, 0.16)",
accent: "#3b5cf6ff",
})
})
test("waits for theme styles rather than publishing an empty palette", () => {
expect(trayAvatar({ name: "Project", variant: "blue" }, { getPropertyValue: () => "" })).toBeUndefined()
})
@@ -0,0 +1,18 @@
import type { TrayAvatar } from "../../shared/tray-avatar"
export function trayAvatar(
project: { name: string; source?: string; variant: string },
style: Pick<CSSStyleDeclaration, "getPropertyValue">,
): TrayAvatar | undefined {
const background = style.getPropertyValue(`--v2-avatar-bg-${project.variant}`).trim()
if (!background) return
return {
name: project.name,
source: project.source,
background,
border: style.getPropertyValue(`--v2-avatar-border-${project.variant}`).trim(),
foreground: style.getPropertyValue("--v2-avatar-fg").trim(),
highlight: style.getPropertyValue("--v2-alpha-light-16").trim(),
accent: style.getPropertyValue("--v2-background-bg-accent").trim(),
}
}
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { traySessionIDs } from "./tray-tabs"
test("publishes local session tabs, not drafts or sessions on another server", () => {
expect(
traySessionIDs([
{ type: "session", server: "sidecar", sessionId: "open" },
{ type: "session", server: "sidecar", sessionId: "open" },
{ type: "draft", server: "sidecar" },
{ type: "session", server: "https://remote.example", sessionId: "remote" },
{ type: "session", server: "wsl:Ubuntu", sessionId: "wsl" },
{ type: "session", server: "sidecar", sessionId: "another" },
]),
).toEqual(["open", "another"])
})
@@ -0,0 +1,9 @@
type Tab = { type: "session"; server: string; sessionId: string } | { type: "draft"; server: string }
export function traySessionIDs(tabs: readonly Tab[]) {
// Drafts have no durable Session yet. The tray currently monitors the base
// service, so remote/WSL tab IDs must never be looked up on that service.
return [
...new Set(tabs.flatMap((tab) => (tab.type === "session" && tab.server === "sidecar" ? [tab.sessionId] : []))),
]
}
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { TrayAvatar } from "../tray-avatar"
const ServerReadyData = Schema.Struct({
url: Schema.String,
@@ -64,6 +65,14 @@ export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
payload: { enabled: Schema.Boolean },
error: Schema.String,
})
export const AppGetTrayEnabled = Rpc.make("AppGetTrayEnabled", { success: Schema.Boolean })
export const AppSetTrayEnabled = Rpc.make("AppSetTrayEnabled", { payload: { enabled: Schema.Boolean } })
export const AppSetTrayTabs = Rpc.make("AppSetTrayTabs", {
payload: {
sessionIDs: Schema.Array(Schema.String),
avatars: Schema.optionalKey(Schema.Record(Schema.String, TrayAvatar)),
},
})
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppReconnectService,
@@ -84,4 +93,7 @@ export const AppRpcs = RpcGroup.make(
AppPairCode,
AppGetKeepScreenActive,
AppSetKeepScreenActive,
AppGetTrayEnabled,
AppSetTrayEnabled,
AppSetTrayTabs,
)
@@ -3,6 +3,7 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { BrowserPaneCaptureRpc, BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
import { MenuCommand } from "../menu-command"
import { SshState } from "@opencode/app/ssh"
export class SshChanged extends Schema.TaggedClass<SshChanged>()("SshChanged", { state: SshState }) {}
@@ -17,7 +18,7 @@ export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("Deep
}) {}
export class MenuCommandTriggered extends Schema.TaggedClass<MenuCommandTriggered>()("MenuCommandTriggered", {
id: Schema.String,
command: MenuCommand,
}) {}
export class UpdaterStateChanged extends Schema.TaggedClass<UpdaterStateChanged>()("UpdaterStateChanged", {
+3 -1
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { MenuCommand } from "../menu-command"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
@@ -27,4 +28,5 @@ const DesktopMenuAction = Schema.Literals([
export const MenuRunAction = Rpc.make("MenuRunAction", {
payload: { action: DesktopMenuAction },
})
export const MenuRpcs = RpcGroup.make(MenuRunAction)
export const MenuReady = Rpc.make("MenuReady", { success: Schema.Array(MenuCommand) })
export const MenuRpcs = RpcGroup.make(MenuRunAction, MenuReady)
@@ -0,0 +1,8 @@
import { Schema } from "effect"
export const MenuCommand = Schema.Union([
Schema.Struct({ type: Schema.Literal("command"), id: Schema.String }),
Schema.Struct({ type: Schema.Literal("home") }),
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
])
export type MenuCommand = typeof MenuCommand.Type
@@ -0,0 +1,12 @@
import { Schema } from "effect"
export const TrayAvatar = Schema.Struct({
name: Schema.String,
source: Schema.optionalKey(Schema.String),
background: Schema.String,
border: Schema.String,
foreground: Schema.String,
highlight: Schema.String,
accent: Schema.String,
})
export type TrayAvatar = typeof TrayAvatar.Type
+39
View File
@@ -0,0 +1,39 @@
export * as TerminalTray from "./terminal-tray.js"
import { Schema } from "effect"
export const Connection = Schema.Struct({
id: Schema.String,
url: Schema.String.check(Schema.isPattern(/^http:\/\/127\.0\.0\.1:\d+$/)),
token: Schema.String,
}).annotate({ identifier: "TerminalTray.Connection" })
export interface Connection extends Schema.Schema.Type<typeof Connection> {}
export const State = Schema.Struct({
enabled: Schema.Boolean,
focused: Schema.Boolean,
sessions: Schema.Array(Schema.String),
current: Schema.optionalKey(Schema.String),
}).annotate({ identifier: "TerminalTray.State" })
export interface State extends Schema.Schema.Type<typeof State> {}
export const Snapshot = Schema.Struct({
...State.fields,
server: Schema.Struct({
url: Schema.String,
auth: Schema.optionalKey(
Schema.Struct({
type: Schema.Literal("basic"),
username: Schema.String,
password: Schema.String,
}),
),
}),
}).annotate({ identifier: "TerminalTray.Snapshot" })
export interface Snapshot extends Schema.Schema.Type<typeof Snapshot> {}
export const Command = Schema.Union([
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
Schema.Struct({ type: Schema.Literals(["new", "settings", "focus"]) }),
]).annotate({ identifier: "TerminalTray.Command" })
export type Command = typeof Command.Type
+37 -2
View File
@@ -103,6 +103,7 @@ import { SessionTerminalsProvider } from "./context/session-terminals"
import { PanelProvider, usePanel } from "./context/panel"
import { SessionFrame } from "./component/session-frame"
import { createTuiClipboard } from "./clipboard"
import type { TerminalTray } from "@opencode/schema/terminal-tray"
registerOpencodeSpinner()
@@ -199,6 +200,10 @@ export type TuiInput = {
| undefined
>
log?: LogSink
tray?: {
update: (state: TerminalTray.State) => void
subscribe: (listener: (command: TerminalTray.Command) => void) => () => void
}
}
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
@@ -403,7 +408,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
packages={input.packages}
directories={pluginDirectories}
>
<App />
<App tray={input.tray} />
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
@@ -456,7 +461,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App() {
function App(props: { tray?: TuiInput["tray"] }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -493,6 +498,36 @@ function App() {
renderer.off(CliRenderEvents.FRAME, afterFrame)
if (paletteTimer) clearTimeout(paletteTimer)
})
createEffect(() => {
const current = sessionTabs.current()
props.tray?.update({
enabled: config.data.tray?.enabled !== false,
focused: sessionTabs.focused(),
sessions: sessionTabs.enabled()
? sessionTabs
.tabs()
.map((tab) => tab.sessionID)
.filter((id) => id !== "dummy")
: current && current !== "dummy"
? [current]
: [],
current,
})
})
onMount(() => {
if (!props.tray) return
onCleanup(
props.tray.subscribe((command) => {
if (command.type === "session") {
dialog.clear()
route.navigate({ type: "session", sessionID: command.sessionID })
return
}
if (command.type === "new") keymap.dispatch("session.new")
if (command.type === "settings") keymap.dispatch("opencode.settings")
}),
)
})
createEffect(() => {
if (client.connection.status() !== "connected") return
if (route.data.type !== "session") return
@@ -289,6 +289,15 @@ export const settings: Setting[] = [
format: (value) => `${Math.round(Number(value) * 100)}%`,
keywords: ["sound volume", "audio volume"],
},
{
title: "Menu bar / system tray",
category: "Terminal",
path: ["tray", "enabled"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["tray", "companion", "menu bar"],
},
{
title: "Window title",
category: "Terminal",
+10 -3
View File
@@ -67,6 +67,13 @@ export const Cursor = Schema.Struct({
}).annotate({ description: "Terminal cursor settings" })
export const Info = Schema.Struct({
tray: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Show the menu-bar/system-tray companion while the terminal app is open",
}),
}),
).annotate({ description: "Native tray companion settings" }),
theme: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String).annotate({ description: "Theme name" }),
@@ -159,7 +166,8 @@ export const Info = Schema.Struct({
description: "Group related transcript items automatically or render each item separately",
}),
verbosity: Schema.optional(Schema.Literals(["low", "medium", "high"])).annotate({
description: "Transcript detail level: low summarizes each run of tools and thoughts, high opens exploration and instruction groups",
description:
"Transcript detail level: low summarizes each run of tools and thoughts, high opens exploration and instruction groups",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
@@ -283,8 +291,7 @@ export function resolve(
input: Info,
options: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> },
): Resolved {
const tabsMode =
input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off")
const tabsMode = input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off")
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds["terminal.suspend"] = "none"
@@ -351,6 +351,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return {
enabled,
focused: () => focused() !== false,
tabs() {
return state().tabs
},
+11
View File
@@ -9,6 +9,17 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind"
const decodeInfo = Schema.decodeUnknownSync(Info)
test("supports opting out of the default-on terminal tray companion", () => {
expect(decodeInfo({ tray: { enabled: false } })).toEqual({ tray: { enabled: false } })
expect(resolve({ tray: { enabled: false } }, { terminalSuspend: true }).tray?.enabled).toBe(false)
expect(settings.find((setting) => setting.path.join(".") === "tray.enabled")).toMatchObject({
category: "Terminal",
default: true,
values: [false, true],
})
expect(() => decodeInfo({ tray: { enabled: "off" } })).toThrow()
})
test("validates the explicit diff source defaults", () => {
for (const source of ["branch", "committed", "working", "turn"] as const) {
expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } })
+1
View File
@@ -46,6 +46,7 @@
"./field": "./src/forms/field/field.tsx",
"./file-tree.css": "./src/styles/file-tree.css",
"./icon": "./src/icons/icon/icon.tsx",
"./icons/shell": "./src/icons/icon/shell-icons.ts",
"./icon-button": "./src/actions/icon-button/icon-button.tsx",
"./inline-input": "./src/forms/inline-input/inline-input.tsx",
"./keybind": "./src/data-display/keybind/keybind.tsx",
+2 -12
View File
@@ -1,5 +1,6 @@
import { onMount, type ComponentProps, splitProps } from "solid-js"
import { additionalIcons, additionalIconViewBox } from "./additional-icons"
import { shellIcons } from "./shell-icons"
import "./icon.css"
// Consumers center the SVG viewport, so each icon must center its artwork within its viewBox.
@@ -16,14 +17,11 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M12.5 6.5V2.5C12.5 1.94772 12.0523 1.5 11.5 1.5H2.5C1.94772 1.5 1.5 1.94772 1.5 2.5V11.5C1.5 12.0523 1.94772 12.5 2.5 12.5H6.5" stroke="currentColor" stroke-linecap="round"/><path d="M7.5 7.5L14.5 10.1L11.1 11.1L10.1 14.5L7.5 7.5Z" stroke="currentColor" stroke-linejoin="round"/>`,
},
...shellIcons,
flask: {
viewBox: "0 0 16 16",
body: `<path d="M5.5 2H10.5M6 2V6L2.5 12C2 13 2.5 14 3.5 14H12.5C13.5 14 14 13 13.5 12L10 6V2M4.25 9H11.75" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
},
edit: {
viewBox: "0 0 16 16",
body: `<path d="M13.5556 8.21529V13.5556H2.44446L2.44446 2.44445H7.78474M6.00002 8.16216V10H7.83786L14 3.83784L12.1622 2L6.00002 8.16216Z" stroke="currentColor"/>`,
},
"folder-add-left": {
viewBox: "0 0 16 16",
body: `<path d="M7.5 13.3333H1.5V2H6.83333L8.83333 4H14.8333V6M10.1667 11.3333H15.5M12.8333 8.66667V14" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
@@ -40,10 +38,6 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M10.4225 3.35355L12.9024 5.83344L10.4225 8.31333" stroke="currentColor"/><path d="M1 12.2852H4.23042C4.89912 12.2852 5.52359 11.951 5.89452 11.3946L9.00783 6.72462C8.37877 6.16823 10.0032 5.83402 10.6719 5.83402H12.9024" stroke="currentColor"/><path d="M8.5 12.2852H14" stroke="currentColor" stroke-linejoin="round"/>`,
},
"grid-plus": {
viewBox: "0 0 16 16",
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
help: {
viewBox: "0 0 16 16",
body: `<path d="M6.33345 6.33349V5.00015H9.66679V7.00015L8.00015 8.00015V9.66679M8.27485 11.6819H7.71897M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z" stroke="currentColor" stroke-linecap="square"/>`,
@@ -84,10 +78,6 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round"/><path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round"/>`,
},
"settings-gear": {
viewBox: "0 0 16 16",
body: `<path d="M7.99998 1.3335L14 4.66683V11.3335L7.99998 14.6668L2 11.3335V4.66683L7.99998 1.3335Z" stroke="currentColor"/><path d="M9.99998 8.00016C9.99998 9.10476 9.10458 10.0002 7.99998 10.0002C6.89538 10.0002 5.99998 9.10476 5.99998 8.00016C5.99998 6.89556 6.89538 6.00016 7.99998 6.00016C9.10458 6.00016 9.99998 6.89556 9.99998 8.00016Z" stroke="currentColor"/>`,
},
"chevron-down": {
viewBox: "0 0 16 16",
body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
+23
View File
@@ -0,0 +1,23 @@
// Shared SVG artwork for web controls and native desktop menus.
export const shellIcons = {
"grid-plus": {
viewBox: "0 0 16 16",
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
},
edit: {
viewBox: "0 0 16 16",
body: `<path d="M13.5556 8.21529V13.5556H2.44446L2.44446 2.44445H7.78474M6.00002 8.16216V10H7.83786L14 3.83784L12.1622 2L6.00002 8.16216Z" stroke="currentColor"/>`,
},
"settings-gear": {
viewBox: "0 0 16 16",
body: `<path d="M7.99998 1.3335L14 4.66683V11.3335L7.99998 14.6668L2 11.3335V4.66683L7.99998 1.3335Z" stroke="currentColor"/><path d="M9.99998 8.00016C9.99998 9.10476 9.10458 10.0002 7.99998 10.0002C6.89538 10.0002 5.99998 9.10476 5.99998 8.00016C5.99998 6.89556 6.89538 6.00016 7.99998 6.00016C9.10458 6.00016 9.99998 6.89556 9.99998 8.00016Z" stroke="currentColor"/>`,
},
"book-open": {
viewBox: "0 0 16 16",
body: `<path d="M8 3.5C6 2.5 4 2 1.5 2.5V12.5C4 12 6 12.5 8 13.5M8 3.5C10 2.5 12 2 14.5 2.5V12.5C12 12 10 12.5 8 13.5M8 3.5V13.5" stroke="currentColor" stroke-linejoin="round"/>`,
},
"log-out": {
viewBox: "0 0 16 16",
body: `<path d="M7.25 2H2.75V14H7.25M9.75 4.5L13.25 8L9.75 11.5M6.25 8H13.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
},
}