mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(desktop): add menu-bar tray companion
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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`], () => {})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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)),
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
/// <reference types="electron-vite/node" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
readonly OPENCODE_VERSION?: string
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ?? []
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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("<")
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
@@ -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"/>`,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user